Merge branch 'main' of https://github.com/GSA/notifications-admin into 1484-dashboard-visualizations

This commit is contained in:
Jonathan Bobel
2024-06-27 11:03:27 -04:00
29 changed files with 324 additions and 196 deletions

View File

@@ -169,7 +169,7 @@
"filename": "app/config.py", "filename": "app/config.py",
"hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc", "hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc",
"is_verified": false, "is_verified": false,
"line_number": 111, "line_number": 117,
"is_secret": false "is_secret": false
} }
], ],

View File

@@ -18,11 +18,11 @@ jobs:
- name: Check for changes to Terraform - name: Check for changes to Terraform
id: changed-terraform-files id: changed-terraform-files
uses: tj-actions/changed-files@v41.0.0 uses: tj-actions/changed-files@v44
with: with:
files: | files: |
terraform/demo terraform/demo/**
terraform/shared terraform/shared/**
.github/workflows/deploy-demo.yml .github/workflows/deploy-demo.yml
- name: Terraform init - name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true' if: steps.changed-terraform-files.outputs.any_changed == 'true'
@@ -88,7 +88,7 @@ jobs:
- name: Check for changes to egress config - name: Check for changes to egress config
id: changed-egress-config id: changed-egress-config
uses: tj-actions/changed-files@v41.0.0 uses: tj-actions/changed-files@v44
with: with:
files: | files: |
deploy-config/egress_proxy/notify-admin-demo.*.acl deploy-config/egress_proxy/notify-admin-demo.*.acl

View File

@@ -18,11 +18,11 @@ jobs:
- name: Check for changes to Terraform - name: Check for changes to Terraform
id: changed-terraform-files id: changed-terraform-files
uses: tj-actions/changed-files@v41.0.0 uses: tj-actions/changed-files@v44
with: with:
files: | files: |
terraform/production terraform/production/**
terraform/shared terraform/shared/**
.github/workflows/deploy-prod.yml .github/workflows/deploy-prod.yml
- name: Terraform init - name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true' if: steps.changed-terraform-files.outputs.any_changed == 'true'
@@ -88,7 +88,7 @@ jobs:
- name: Check for changes to egress config - name: Check for changes to egress config
id: changed-egress-config id: changed-egress-config
uses: tj-actions/changed-files@v41.0.0 uses: tj-actions/changed-files@v44
with: with:
files: | files: |
deploy-config/egress_proxy/notify-admin-production.*.acl deploy-config/egress_proxy/notify-admin-production.*.acl

View File

@@ -17,96 +17,96 @@ jobs:
environment: staging environment: staging
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Check for changes to Terraform - name: Check for changes to Terraform
id: changed-terraform-files id: changed-terraform-files
uses: tj-actions/changed-files@v41.0.0 uses: tj-actions/changed-files@v44
with: with:
files: | files: |
terraform/staging terraform/staging/**
terraform/shared terraform/shared/**
.github/workflows/deploy.yml .github/workflows/deploy.yml
- name: Terraform init - name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true' if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging working-directory: terraform/staging
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init run: terraform init
- name: Terraform apply - name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true' if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging working-directory: terraform/staging
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }} TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }}
TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
run: terraform apply -auto-approve -input=false run: terraform apply -auto-approve -input=false
- uses: ./.github/actions/setup-project - uses: ./.github/actions/setup-project
- name: Create requirements.txt - name: Create requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov - name: Deploy to cloud.gov
uses: 18f/cg-deploy-action@main uses: 18f/cg-deploy-action@main
env: env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }} DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }} SECRET_KEY: ${{ secrets.SECRET_KEY }}
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }} ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }} NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }} NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
COMMIT_HASH: ${{ github.sha }} COMMIT_HASH: ${{ github.sha }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }} LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov" LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov"
LOGIN_DOT_GOV_USER_INFO_URL: "https://secure.login.gov/api/openid_connect/userinfo" LOGIN_DOT_GOV_USER_INFO_URL: "https://secure.login.gov/api/openid_connect/userinfo"
LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://secure.login.gov/api/openid_connect/token" LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://secure.login.gov/api/openid_connect/token"
LOGIN_DOT_GOV_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://notify-staging.app.cloud.gov/sign-out" LOGIN_DOT_GOV_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://notify-staging.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?" LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?"
LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://notify-staging.app.cloud.gov/sign-out" LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://notify-staging.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_INITIAL_SIGNIN_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=NONCE&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/sign-in&response_type=code&scope=openid+email&state=STATEE" LOGIN_DOT_GOV_INITIAL_SIGNIN_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=NONCE&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/sign-in&response_type=code&scope=openid+email&state=STATEE"
with: with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }} cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio cf_org: gsa-tts-benefits-studio
cf_space: notify-staging cf_space: notify-staging
push_arguments: >- push_arguments: >-
--vars-file deploy-config/staging.yml --vars-file deploy-config/staging.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT" --var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY" --var SECRET_KEY="$SECRET_KEY"
--var ADMIN_CLIENT_USERNAME="notify-admin" --var ADMIN_CLIENT_USERNAME="notify-admin"
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET" --var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY" --var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY" --var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var COMMIT_HASH="$COMMIT_HASH" --var COMMIT_HASH="$COMMIT_HASH"
--var LOGIN_PEM="$LOGIN_PEM" --var LOGIN_PEM="$LOGIN_PEM"
--var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID" --var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID"
--var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL" --var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL"
--var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL" --var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL"
--var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL" --var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL"
--var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL" --var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL"
--var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT" --var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT"
--var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL" --var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL"
- name: Check for changes to egress config - name: Check for changes to egress config
id: changed-egress-config id: changed-egress-config
uses: tj-actions/changed-files@v41.0.0 uses: tj-actions/changed-files@v44
with: with:
files: | files: |
deploy-config/egress_proxy/notify-admin-staging.*.acl deploy-config/egress_proxy/notify-admin-staging.*.acl
.github/actions/deploy-proxy/action.yml .github/actions/deploy-proxy/action.yml
.github/workflows/deploy.yml .github/workflows/deploy.yml
- name: Deploy egress proxy - name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true' if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy uses: ./.github/actions/deploy-proxy
with: with:
cf_space: notify-staging cf_space: notify-staging
app: notify-admin-staging app: notify-admin-staging
bail: bail:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -40,7 +40,7 @@ You will need the following items:
[Follow the instructions here to set up the Notify.gov API.](https://github.com/GSA/notifications-api#before-you-start) [Follow the instructions here to set up the Notify.gov API.](https://github.com/GSA/notifications-api#before-you-start)
The Notify.gov API is required in order for the Notify.gov Admin UI to run, and The Notify.gov API is required in order for the Notify.gov Admin UI to run, and
it will also take care of many of the steps that are listed here. The sections it will also take care of many of the steps that are listed here. The sections
that are a repeat from the API setup are flagged with an **[API Step]** label that are a repeat from the API setup are flagged with an **[API Step]** label
in front of them. in front of them.
@@ -83,11 +83,13 @@ Your system `$PATH` environment variable is likely set in one of these
locations: locations:
For BASH shells: For BASH shells:
- `~/.bashrc` - `~/.bashrc`
- `~/.bash_profile` - `~/.bash_profile`
- `~/.profile` - `~/.profile`
For ZSH shells: For ZSH shells:
- `~/.zshrc` - `~/.zshrc`
- `~/.zprofile` - `~/.zprofile`
@@ -97,7 +99,7 @@ environments.
Which file you need to modify depends on whether or not you are running an Which file you need to modify depends on whether or not you are running an
interactive shell or a login shell interactive shell or a login shell
(see [this Stack Overflow post](https://stackoverflow.com/questions/18186929/what-are-the-differences-between-a-login-shell-and-interactive-shell) (see [this Stack Overflow post](https://stackoverflow.com/questions/18186929/what-are-the-differences-between-a-login-shell-and-interactive-shell)
for an explanation of the differences). If you're still not sure, please ask for an explanation of the differences). If you're still not sure, please ask
the team for help! the team for help!
Once you determine which file you'll need to modify, add these lines before any Once you determine which file you'll need to modify, add these lines before any
@@ -158,7 +160,7 @@ _NOTE: This project currently uses the latest `1.4.x release of Terraform._
#### [API Step] Python Installation #### [API Step] Python Installation
Now we're going to install a tool to help us manage Python versions and Now we're going to install a tool to help us manage Python versions and
virtual environments on our system. First, we'll install virtual environments on our system. First, we'll install
[pyenv](https://github.com/pyenv/pyenv) and one of its plugins, [pyenv](https://github.com/pyenv/pyenv) and one of its plugins,
[pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv), with Homebrew: [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv), with Homebrew:
@@ -285,7 +287,7 @@ we'll use `3.12` in our example here since we recently upgraded to this version:
pyenv install 3.12 pyenv install 3.12
``` ```
Next, delete the virtual environment you previously had set up. If you followed 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`: the instructions above with the first-time set up, you can do this with `pyenv`:
```sh ```sh
@@ -306,6 +308,20 @@ 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`._ _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`._
#### Updating the .env file for Login.gov
To configure the application for Login.gov, you will need to update the following environment variables in the .env file:
```
COMMIT_HASH=”--------”
```
Reach out to someone on the team to get the most recent Login.gov key.
```
LOGIN_PEM="INSERT_LOGIN_GOV_KEY_HERE"
```
#### Updating the .env file for E2E tests #### Updating the .env file for E2E tests
With the newly created `.env` file in place, you'll need to make one more With the newly created `.env` file in place, you'll need to make one more
@@ -353,7 +369,7 @@ API is running as well!
## Creating a 'First User' in the database ## Creating a 'First User' in the database
After you have completed all setup steps, you will be unable to log in, because there After you have completed all setup steps, you will be unable to log in, because there
will not be a user in the database to link to the login.gov account you are using. So will not be a user in the database to link to the login.gov account you are using. So
you will need to create that user in your database using the 'create-test-user' command. you will need to create that user in your database using the 'create-test-user' command.
Open two terminals pointing to the api project and then run these commands in the Open two terminals pointing to the api project and then run these commands in the
@@ -372,8 +388,6 @@ is the same one you are using in login.gov and make sure your phone number is in
If for any reason in the course of development it is necessary for your to delete your db If for any reason in the course of development it is necessary for your to delete your db
via the `dropdb` command, you will need to repeat these steps when you recreate your db. via the `dropdb` command, you will need to repeat these steps when you recreate your db.
## Git Hooks ## Git Hooks
We're using [`pre-commit`](https://pre-commit.com/) to manage hooks in order to We're using [`pre-commit`](https://pre-commit.com/) to manage hooks in order to

View File

@@ -53,7 +53,13 @@ class Config(object):
PERMANENT_SESSION_LIFETIME = 1800 # 30 Minutes PERMANENT_SESSION_LIFETIME = 1800 # 30 Minutes
SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year
REPLY_TO_EMAIL_ADDRESS_VALIDATION_TIMEOUT = 45 REPLY_TO_EMAIL_ADDRESS_VALIDATION_TIMEOUT = 45
ACTIVITY_STATS_LIMIT_DAYS = 7 ACTIVITY_STATS_LIMIT_DAYS = {
"today": 0,
"one_day": 1,
"three_day": 3,
"five_day": 5,
"seven_day": 7,
}
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_NAME = "notify_admin_session" SESSION_COOKIE_NAME = "notify_admin_session"
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True

View File

@@ -143,11 +143,40 @@ def view_notifications(service_id, message_type=None):
True: ["reference"], True: ["reference"],
False: [], False: [],
}.get(bool(current_service.api_keys)), }.get(bool(current_service.api_keys)),
download_link=url_for( download_link_one_day=url_for(
".download_notifications_csv", ".download_notifications_csv",
service_id=current_service.id, service_id=current_service.id,
message_type=message_type, message_type=message_type,
status=request.args.get("status"), status=request.args.get("status"),
number_of_days="one_day",
),
download_link_today=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="today",
),
download_link_three_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="three_day",
),
download_link_five_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="five_day",
),
download_link_seven_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="seven_day",
), ),
) )
@@ -183,10 +212,9 @@ def get_notifications(service_id, message_type, status_override=None): # noqa
filter_args["status"] = set_status_filters(filter_args) filter_args["status"] = set_status_filters(filter_args)
service_data_retention_days = None service_data_retention_days = None
search_term = request.form.get("to", "") search_term = request.form.get("to", "")
if message_type is not None: if message_type is not None:
service_data_retention_days = current_service.get_days_of_retention( service_data_retention_days = current_service.get_days_of_retention(
message_type message_type, number_of_days="seven_day"
) )
if request.path.endswith("csv") and current_user.has_permissions("view_activity"): if request.path.endswith("csv") and current_user.has_permissions("view_activity"):
@@ -212,7 +240,6 @@ def get_notifications(service_id, message_type, status_override=None): # noqa
) )
url_args = {"message_type": message_type, "status": request.args.get("status")} url_args = {"message_type": message_type, "status": request.args.get("status")}
prev_page = None prev_page = None
if "links" in notifications and notifications["links"].get("prev", None): if "links" in notifications and notifications["links"].get("prev", None):
prev_page = generate_previous_dict( prev_page = generate_previous_dict(
"main.view_notifications", service_id, page, url_args=url_args "main.view_notifications", service_id, page, url_args=url_args
@@ -233,7 +260,6 @@ def get_notifications(service_id, message_type, status_override=None): # noqa
) )
else: else:
download_link = None download_link = None
return { return {
"service_data_retention_days": service_data_retention_days, "service_data_retention_days": service_data_retention_days,
"counts": render_template( "counts": render_template(
@@ -362,6 +388,7 @@ def get_job_partials(job):
filter_args = parse_filter_args(request.args) filter_args = parse_filter_args(request.args)
filter_args["status"] = set_status_filters(filter_args) filter_args["status"] = set_status_filters(filter_args)
notifications = job.get_notifications(status=filter_args["status"]) notifications = job.get_notifications(status=filter_args["status"])
number_of_days = "seven_day"
counts = render_template( counts = render_template(
"partials/count.html", "partials/count.html",
counts=_get_job_counts(job), counts=_get_job_counts(job),
@@ -371,7 +398,7 @@ def get_job_partials(job):
), ),
) )
service_data_retention_days = current_service.get_days_of_retention( service_data_retention_days = current_service.get_days_of_retention(
job.template_type job.template_type, number_of_days
) )
if request.referrer is not None: if request.referrer is not None:

View File

@@ -137,9 +137,9 @@ def get_all_personalisation_from_notification(notification):
def download_notifications_csv(service_id): def download_notifications_csv(service_id):
filter_args = parse_filter_args(request.args) filter_args = parse_filter_args(request.args)
filter_args["status"] = set_status_filters(filter_args) filter_args["status"] = set_status_filters(filter_args)
number_of_days = request.args["number_of_days"]
service_data_retention_days = current_service.get_days_of_retention( service_data_retention_days = current_service.get_days_of_retention(
filter_args.get("message_type")[0] filter_args.get("message_type")[0], number_of_days
) )
file_time = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") file_time = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
file_time = f"{file_time} {get_user_preferred_timezone()}" file_time = f"{file_time} {get_user_preferred_timezone()}"

View File

@@ -51,7 +51,6 @@ from app.utils.templates import get_template
from app.utils.user import user_has_permissions from app.utils.user import user_has_permissions
from notifications_utils import SMS_CHAR_COUNT_LIMIT from notifications_utils import SMS_CHAR_COUNT_LIMIT
from notifications_utils.insensitive_dict import InsensitiveDict from notifications_utils.insensitive_dict import InsensitiveDict
from notifications_utils.logging import scrub
from notifications_utils.recipients import RecipientCSV, first_column_headings from notifications_utils.recipients import RecipientCSV, first_column_headings
from notifications_utils.sanitise_text import SanitiseASCII from notifications_utils.sanitise_text import SanitiseASCII
@@ -953,9 +952,6 @@ def send_notification(service_id, template_id):
) )
) )
current_app.logger.info(
hilite(scrub(f"Recipient for the one-off will be {recipient}"))
)
keys = [] keys = []
values = [] values = []
for k, v in session["placeholders"].items(): for k, v in session["placeholders"].items():
@@ -971,6 +967,12 @@ def send_notification(service_id, template_id):
) )
my_data = {"filename": filename, "template_id": template_id, "data": data} my_data = {"filename": filename, "template_id": template_id, "data": data}
upload_id = s3upload(service_id, my_data) upload_id = s3upload(service_id, my_data)
# To debug messages that the user reports have not been sent, we log
# the csv filename and the job id. The user will give us the file name,
# so we can search on that to obtain the job id, which we can use elsewhere
# on the API side to find out what happens to the message.
current_app.logger.info(hilite(f"One-off file: {filename} job_id: {upload_id}"))
form = CsvUploadForm() form = CsvUploadForm()
form.file.data = my_data form.file.data = my_data
form.file.name = filename form.file.name = filename
@@ -989,19 +991,6 @@ def send_notification(service_id, template_id):
valid="True", valid="True",
) )
# Here we are attempting to cleverly link the job id to the one-off recipient
# If we know the partial phone number of the recipient, we can search
# on that initially and find this, which will give us the job_id
# And once we know the job_id, we can search on that and it might tell us something
# about report generation.
current_app.logger.info(
hilite(
scrub(
f"Created job to send one-off, recipient is {recipient}, job_id is {upload_id}"
)
)
)
session.pop("recipient") session.pop("recipient")
session.pop("placeholders") session.pop("placeholders")
@@ -1033,7 +1022,12 @@ def send_notification(service_id, template_id):
job_id=upload_id, job_id=upload_id,
) )
) )
total = notifications["total"]
current_app.logger.info(
hilite(
f"job_id: {upload_id} has notifications: {total} and attempts: {attempts}"
)
)
return redirect( return redirect(
url_for( url_for(
".view_job", ".view_job",

View File

@@ -390,7 +390,7 @@ class Service(JSONModel, SortByNameMixin):
def get_data_retention_item(self, id): def get_data_retention_item(self, id):
return next((dr for dr in self.data_retention if dr["id"] == id), None) return next((dr for dr in self.data_retention if dr["id"] == id), None)
def get_days_of_retention(self, notification_type): def get_days_of_retention(self, notification_type, number_of_days):
return next( return next(
( (
dr dr
@@ -398,7 +398,10 @@ class Service(JSONModel, SortByNameMixin):
if dr["notification_type"] == notification_type if dr["notification_type"] == notification_type
), ),
{}, {},
).get("days_of_retention", current_app.config["ACTIVITY_STATS_LIMIT_DAYS"]) ).get(
"days_of_retention",
current_app.config["ACTIVITY_STATS_LIMIT_DAYS"].get(number_of_days),
)
@cached_property @cached_property
def organization(self): def organization(self):

View File

@@ -64,10 +64,22 @@
{% if current_user.has_permissions('view_activity') %} {% if current_user.has_permissions('view_activity') %}
<p class="font-body-sm"> <p class="font-body-sm">
<a href="{{ download_link }}" download="download" class="usa-link">Download this report (<abbr title="Comma separated values">CSV</abbr>)</a> <a href="{{ download_link_seven_day }}" download="download" class="usa-link">Download all data last 7 days (<abbr title="Comma separated values">CSV</abbr>)</a>
&emsp; &emsp;
Data available for {{ partials.service_data_retention_days }} days Data available for {{ partials.service_data_retention_days }} days
</p> </p>
<p class="font-body-sm">
<a href="{{ download_link_five_day }}" download="download" class="usa-link">Download all data last 5 days (<abbr title="Comma separated values">CSV</abbr>)</a>
&emsp;
</p>
<p class="font-body-sm">
<a href="{{ download_link_three_day }}" download="download" class="usa-link">Download all data last 3 days (<abbr title="Comma separated values">CSV</abbr>)</a>
&emsp;
</p>
<p class="font-body-sm">
<a href="{{ download_link_today }}" download="download" class="usa-link">Download all data today (<abbr title="Comma separated values">CSV</abbr>)</a>
&emsp;
</p>
{% endif %} {% endif %}
{{ ajax_block( {{ ajax_block(

View File

@@ -7,7 +7,6 @@ from flask_login import current_user
from app.models.spreadsheet import Spreadsheet from app.models.spreadsheet import Spreadsheet
from app.utils import hilite from app.utils import hilite
from app.utils.templates import get_sample_template from app.utils.templates import get_sample_template
from notifications_utils.logging import scrub
from notifications_utils.recipients import RecipientCSV from notifications_utils.recipients import RecipientCSV
@@ -74,12 +73,11 @@ def generate_notifications_csv(**kwargs):
# This generates the "batch" csv report # This generates the "batch" csv report
if kwargs.get("job_id"): if kwargs.get("job_id"):
# The kwargs contain the job id, which is linked to the recipient's partial phone number in other debug
# Some unit tests are mocking the kwargs and turning them into a function instead of dict, # Some unit tests are mocking the kwargs and turning them into a function instead of dict,
# hence the try/except. # hence the try/except.
try: try:
current_app.logger.info( current_app.logger.info(
hilite(f"Setting up report with kwargs {scrub(json.dumps(kwargs))}") hilite(f"Setting up report with kwargs {json.dumps(kwargs)}")
) )
except TypeError: except TypeError:
pass pass
@@ -89,7 +87,7 @@ def generate_notifications_csv(**kwargs):
# we display to 999 characters, because we don't want to show the contents for reports with thousands of rows. # we display to 999 characters, because we don't want to show the contents for reports with thousands of rows.
current_app.logger.info( current_app.logger.info(
hilite( hilite(
f"Original csv for job_id {kwargs['job_id']}: {scrub(original_file_contents[0:999])}" f"Original csv for job_id {kwargs['job_id']}: {original_file_contents[0:999]}"
) )
) )
original_upload = RecipientCSV( original_upload = RecipientCSV(

View File

@@ -1,6 +1,6 @@
env: production env: production
instances: 2 instances: 2
memory: 1G memory: 2G
public_admin_route: beta.notify.gov public_admin_route: beta.notify.gov
cloud_dot_gov_route: notify.app.cloud.gov cloud_dot_gov_route: notify.app.cloud.gov
redis_enabled: 1 redis_enabled: 1

View File

@@ -5,7 +5,10 @@ import multiprocessing
import gunicorn import gunicorn
# Let gunicorn figure out the right number of workers # Let gunicorn figure out the right number of workers
workers = multiprocessing.cpu_count() * 2 + 1 # The recommended formula is cpu_count() * 2 + 1
# but we have an unusual configuration with a lot of cpus and not much memory
# so adjust it.
workers = multiprocessing.cpu_count()
worker_class = "eventlet" worker_class = "eventlet"
bind = "0.0.0.0:{}".format(os.getenv("PORT")) bind = "0.0.0.0:{}".format(os.getenv("PORT"))
disable_redirect_access_to_syslog = True disable_redirect_access_to_syslog = True
@@ -16,23 +19,3 @@ def worker_abort(worker):
worker.log.info("worker received ABORT") worker.log.info("worker received ABORT")
for stack in sys._current_frames().values(): for stack in sys._current_frames().values():
worker.log.error("".join(traceback.format_stack(stack))) worker.log.error("".join(traceback.format_stack(stack)))
# This issue is fixed in the 22.0.0 release, which we are using
# See github issue for details
# def fix_ssl_monkeypatching():
# """
# eventlet works by monkey-patching core IO libraries (such as ssl) to be non-blocking. However, there's currently
# a bug: In the normal socket library it may throw a timeout error as a `socket.timeout` exception. However
# eventlet.green.ssl's patch raises an ssl.SSLError('timed out',) instead. redispy handles socket.timeout but not
# ssl.SSLError, so we solve this by monkey patching the monkey patching code to raise the correct exception type
# :scream:
# https://github.com/eventlet/eventlet/issues/692
# """
# # this has probably already been called somewhere in gunicorn internals, however, to be sure, we invoke it again.
# # eventlet.monkey_patch can be called multiple times without issue
# eventlet.monkey_patch()
# eventlet.green.ssl.timeout_exc = socket.timeout
# fix_ssl_monkeypatching()

View File

@@ -12,7 +12,7 @@ applications:
- route: ((cloud_dot_gov_route)) - route: ((cloud_dot_gov_route))
services: services:
- notify-admin-redis-((env)) - notify-admin-redis-v70-((env))
- notify-api-csv-upload-bucket-((env)) - notify-api-csv-upload-bucket-((env))
- notify-admin-logo-upload-bucket-((env)) - notify-admin-logo-upload-bucket-((env))

View File

@@ -70,6 +70,7 @@ def configure_handler(handler, app, formatter):
handler.addFilter(AppNameFilter(app.config["NOTIFY_APP_NAME"])) handler.addFilter(AppNameFilter(app.config["NOTIFY_APP_NAME"]))
handler.addFilter(RequestIdFilter()) handler.addFilter(RequestIdFilter())
handler.addFilter(ServiceIdFilter()) handler.addFilter(ServiceIdFilter())
handler.addFilter(PIIFilter())
return handler return handler
@@ -134,13 +135,30 @@ class JSONFormatter(BaseJSONFormatter):
return log_record return log_record
def scrub(msg): class PIIFilter(logging.Filter):
# Eventually we want to scrub all messages in all logs for phone numbers def scrub(self, msg):
# and email addresses, masking them. Ultimately this will probably get # Eventually we want to scrub all messages in all logs for phone numbers
# refactored into a 'SafeLogger' subclass or something, but let's start here # and email addresses, masking them. Ultimately this will probably get
# with phones. # refactored into a 'SafeLogger' subclass or something, but let's start here
phones = re.findall("(?:\\+ *)?\\d[\\d\\- ]{7,}\\d", msg) # with phones.
phones = [phone.replace("-", "").replace(" ", "") for phone in phones]
for phone in phones: # Sometimes just an exception object is passed in for the message, skip those.
msg = msg.replace(phone, f"1XXXXX{phone[-5:]}") if not isinstance(msg, str):
return msg return msg
phones = re.findall("(?:\\+ *)?\\d[\\d\\- ]{7,}\\d", msg)
phones = [phone.replace("-", "").replace(" ", "") for phone in phones]
for phone in phones:
msg = msg.replace(phone, "1XXXXXXXXXX")
emails = re.findall(
r"[\w\.-]+@[\w\.-]+", msg
) # ['alice@google.com', 'bob@abc.com']
for email in emails:
# do something with each found email string
masked_email = "XXXXX@XXXXXXX"
msg = msg.replace(email, masked_email)
return msg
def filter(self, record):
record.msg = self.scrub(record.msg)
return record

View File

@@ -1,9 +1,9 @@
terraform { terraform {
required_version = "~> 1.0" required_version = "~> 1.7"
required_providers { required_providers {
cloudfoundry = { cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry" source = "cloudfoundry-community/cloudfoundry"
version = "0.53.0" version = "0.53.1"
} }
} }
} }

View File

@@ -6,7 +6,7 @@ locals {
recursive_delete = false recursive_delete = false
} }
module "redis" { module "redis" { # default v6.2; delete after v7.0 resource is bound
source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1"
cf_org_name = local.cf_org_name cf_org_name = local.cf_org_name
@@ -16,6 +16,20 @@ module "redis" {
redis_plan_name = "redis-dev" redis_plan_name = "redis-dev"
} }
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-v70-${local.env}"
redis_plan_name = "redis-dev"
json_params = jsonencode(
{
"engineVersion" : "7.0",
}
)
}
module "logo_upload_bucket" { module "logo_upload_bucket" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1"

View File

@@ -1,9 +1,9 @@
terraform { terraform {
required_version = "~> 1.0" required_version = "~> 1.7"
required_providers { required_providers {
cloudfoundry = { cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry" source = "cloudfoundry-community/cloudfoundry"
version = "0.53.0" version = "0.53.1"
} }
} }

View File

@@ -1,9 +1,9 @@
terraform { terraform {
required_version = "~> 1.0" required_version = "~> 1.7"
required_providers { required_providers {
cloudfoundry = { cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry" source = "cloudfoundry-community/cloudfoundry"
version = "0.53.0" version = "0.53.1"
} }
} }
} }

View File

@@ -6,7 +6,7 @@ locals {
recursive_delete = false recursive_delete = false
} }
module "redis" { module "redis" { # default v6.2; delete after v7.0 resource is bound
source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1"
cf_org_name = local.cf_org_name cf_org_name = local.cf_org_name
@@ -16,6 +16,20 @@ module "redis" {
redis_plan_name = "redis-3node-large" redis_plan_name = "redis-3node-large"
} }
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-v70-${local.env}"
redis_plan_name = "redis-3node-large"
json_params = jsonencode(
{
"engineVersion" : "7.0",
}
)
}
module "logo_upload_bucket" { module "logo_upload_bucket" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1"

View File

@@ -1,9 +1,9 @@
terraform { terraform {
required_version = "~> 1.0" required_version = "~> 1.7"
required_providers { required_providers {
cloudfoundry = { cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry" source = "cloudfoundry-community/cloudfoundry"
version = "0.53.0" version = "0.53.1"
} }
} }

View File

@@ -6,7 +6,7 @@ locals {
recursive_delete = true recursive_delete = true
} }
module "redis" { module "redis" { # default v6.2; delete after v7.0 resource is bound
source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1"
cf_org_name = local.cf_org_name cf_org_name = local.cf_org_name
@@ -16,6 +16,20 @@ module "redis" {
redis_plan_name = "redis-dev" redis_plan_name = "redis-dev"
} }
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-v70-${local.env}"
redis_plan_name = "redis-dev"
json_params = jsonencode(
{
"engineVersion" : "7.0",
}
)
}
module "logo_upload_bucket" { module "logo_upload_bucket" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1"

View File

@@ -1,9 +1,9 @@
terraform { terraform {
required_version = "~> 1.0" required_version = "~> 1.7"
required_providers { required_providers {
cloudfoundry = { cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry" source = "cloudfoundry-community/cloudfoundry"
version = "0.53.0" version = "0.53.1"
} }
} }

View File

@@ -1,9 +1,9 @@
terraform { terraform {
required_version = "~> 1.0" required_version = "~> 1.7"
required_providers { required_providers {
cloudfoundry = { cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry" source = "cloudfoundry-community/cloudfoundry"
version = "0.53.0" version = "0.53.1"
} }
} }
} }

View File

@@ -6,7 +6,15 @@ locals {
recursive_delete = true recursive_delete = true
} }
module "redis" { resource "null_resource" "prevent_destroy" {
lifecycle {
prevent_destroy = false # destroying staging is allowed
}
}
module "redis" { # default v6.2; delete after v7.0 resource is bound
source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1"
cf_org_name = local.cf_org_name cf_org_name = local.cf_org_name
@@ -16,6 +24,20 @@ module "redis" {
redis_plan_name = "redis-dev" redis_plan_name = "redis-dev"
} }
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-v70-${local.env}"
redis_plan_name = "redis-dev"
json_params = jsonencode(
{
"engineVersion" : "7.0",
}
)
}
module "logo_upload_bucket" { module "logo_upload_bucket" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1" source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1"

View File

@@ -1,9 +1,9 @@
terraform { terraform {
required_version = "~> 1.0" required_version = "~> 1.7"
required_providers { required_providers {
cloudfoundry = { cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry" source = "cloudfoundry-community/cloudfoundry"
version = "0.53.0" version = "0.53.1"
} }
} }

View File

@@ -228,12 +228,18 @@ def test_can_show_notifications_if_data_retention_not_available(
url_for, url_for,
".download_notifications_csv", ".download_notifications_csv",
message_type=None, message_type=None,
number_of_days="seven_day",
), ),
), ),
( (
create_active_user_with_permissions(), create_active_user_with_permissions(),
{"status": "failed"}, {"status": "failed"},
partial(url_for, ".download_notifications_csv", status="failed"), partial(
url_for,
".download_notifications_csv",
status="failed",
number_of_days="seven_day",
),
), ),
( (
create_active_user_with_permissions(), create_active_user_with_permissions(),
@@ -242,15 +248,13 @@ def test_can_show_notifications_if_data_retention_not_available(
url_for, url_for,
".download_notifications_csv", ".download_notifications_csv",
message_type="sms", message_type="sms",
number_of_days="seven_day",
), ),
), ),
( (
create_active_user_view_permissions(), create_active_user_view_permissions(),
{}, {},
partial( partial(url_for, ".download_notifications_csv", number_of_days="seven_day"),
url_for,
".download_notifications_csv",
),
), ),
( (
create_active_caseworking_user(), create_active_caseworking_user(),

View File

@@ -51,11 +51,16 @@ def test_base_json_formatter_contains_service_id():
assert service_id_filter.filter(record).service_id == "no-service-id" assert service_id_filter.filter(record).service_id == "no-service-id"
def test_scrub(): def test_pii_filter():
result = logging.scrub( record = builtin_logging.LogRecord(
"This is a message with 17775554324, and also 18884449323 and also 17775554324" name="log thing",
) level="info",
assert ( pathname="path",
result lineno=123,
== "This is a message with 1XXXXX54324, and also 1XXXXX49323 and also 1XXXXX54324" msg="phone1: 1555555555, phone2: 1555555554, email1: fake@fake.gov, email2: fake@fake2.fake.gov",
exc_info=None,
args=None,
) )
pii_filter = logging.PIIFilter()
clean_msg = "phone1: 1XXXXXXXXXX, phone2: 1XXXXXXXXXX, email1: XXXXX@XXXXXXX, email2: XXXXX@XXXXXXX"
assert pii_filter.filter(record).msg == clean_msg