mirror of
https://github.com/GSA/notifications-api.git
synced 2026-08-21 23:06:10 -04:00
Compare commits
67 Commits
fips
...
stvnrlly-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17520a0d4 | ||
|
|
d6881afd0d | ||
|
|
68512a272f | ||
|
|
f6e91db5bf | ||
|
|
053b6636cf | ||
|
|
03e7af2c12 | ||
|
|
4fd6ab137a | ||
|
|
eb31120035 | ||
|
|
91f1dbac01 | ||
|
|
4708d3bea0 | ||
|
|
31d116ab18 | ||
|
|
70e4749337 | ||
|
|
8adf1a19bb | ||
|
|
d6633aea5c | ||
|
|
63e288ae3e | ||
|
|
2b401c2a02 | ||
|
|
7489743109 | ||
|
|
3eca4b1d99 | ||
|
|
d620a8cd90 | ||
|
|
f6c34ed2dc | ||
|
|
a3ab2234c0 | ||
|
|
fe24af7342 | ||
|
|
f55b7bf931 | ||
|
|
0a3260c030 | ||
|
|
030d22bd6d | ||
|
|
673debe4ff | ||
|
|
c34b24014a | ||
|
|
007741fe44 | ||
|
|
7d6fb2c645 | ||
|
|
7d29342c5d | ||
|
|
91ece7576d | ||
|
|
ec79a165f4 | ||
|
|
da545f9a67 | ||
|
|
518e2264a6 | ||
|
|
4da08adff2 | ||
|
|
b9afd54ce8 | ||
|
|
668ab251d4 | ||
|
|
e941ccd0cb | ||
|
|
36e730221d | ||
|
|
4a0e555696 | ||
|
|
ff3f2c06c0 | ||
|
|
590b3356a1 | ||
|
|
5a15390336 | ||
|
|
d73b223d48 | ||
|
|
6f6353e0f2 | ||
|
|
e35c68345b | ||
|
|
6eeb7e3172 | ||
|
|
e9e5f8d5c0 | ||
|
|
8b75424e29 | ||
|
|
33bd3b3a31 | ||
|
|
14a1d499fa | ||
|
|
42447fcb8c | ||
|
|
90f3dde344 | ||
|
|
d6f35cf6da | ||
|
|
3130feacf2 | ||
|
|
7e70ba33ea | ||
|
|
356a8c451f | ||
|
|
8930a53bea | ||
|
|
119c5800c4 | ||
|
|
b08dc4375d | ||
|
|
cb7b42c2cc | ||
|
|
4260576fb2 | ||
|
|
edb0ddf2ea | ||
|
|
0252391c88 | ||
|
|
e1f517ebd5 | ||
|
|
0e679c846d | ||
|
|
ab6e2f9698 |
1
.github/ISSUE_TEMPLATE/config.yml
vendored
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
63
.github/ISSUE_TEMPLATE/create-new-adr-form.yml
vendored
63
.github/ISSUE_TEMPLATE/create-new-adr-form.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Create a new ADR
|
||||
description: Create a new issue with an ADR proposal
|
||||
labels: ["ADR: proposed", "documentation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form to create a new Architectural Decision Record.
|
||||
- type: textarea
|
||||
id: context
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Context
|
||||
description: >
|
||||
Explain the nature of the problem, challenge, or decision to be made here.
|
||||
Be as succinct as possible, but do not leave out any details. State things
|
||||
matter of factly and as objectively as you can.
|
||||
- type: textarea
|
||||
id: decision
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Decision
|
||||
description: >
|
||||
What was decided? This should be an active-voice statement. For example,
|
||||
"We will use the US Web Design System." Note any major options that were
|
||||
not selected.
|
||||
- type: textarea
|
||||
id: consequences
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Consequences
|
||||
description: >
|
||||
What are the consequences of this decision? Consequences can be
|
||||
positive, negative, or neutral, but all known consequences of this
|
||||
decision should be listed here.
|
||||
- type: input
|
||||
id: author
|
||||
attributes:
|
||||
label: Author
|
||||
description: Who is the author(s) of this ADR?
|
||||
placeholder: Enter your GitHub username here, and anyone else helping author this
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: stakeholders
|
||||
attributes:
|
||||
label: Stakeholders
|
||||
description: Who are the stakeholders of this ADR?
|
||||
placeholder: Enter their GitHub username(s) here
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: next-steps
|
||||
attributes:
|
||||
label: Next Steps
|
||||
description: >
|
||||
Once option has already been chosen and justified, summarize or list the next steps
|
||||
that came from the decision. Link to GitHub issues that track these steps.
|
||||
validations:
|
||||
required: false
|
||||
4
.github/actions/deploy-proxy/action.yml
vendored
4
.github/actions/deploy-proxy/action.yml
vendored
@@ -36,7 +36,7 @@ runs:
|
||||
- name: Copy config files
|
||||
shell: bash
|
||||
run: cp ./deploy-config/egress_proxy/${{ inputs.app }}.*.acl ${{ steps.create-temp-dir.outputs.path }}
|
||||
- name: Deploy proxy
|
||||
- name: Build and deploy proxy
|
||||
shell: bash
|
||||
working-directory: ${{ steps.create-temp-dir.outputs.path }}
|
||||
run: ./bin/cf-deployproxy -a ${{ inputs.app }} -p egress-proxy -e egress_proxy
|
||||
run: make && ./bin/cf-deployproxy -a ${{ inputs.app }} -p egress-proxy -e egress_proxy
|
||||
|
||||
10
.github/actions/setup-project/action.yml
vendored
10
.github/actions/setup-project/action.yml
vendored
@@ -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.12
|
||||
uses: actions/setup-python@v4
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install poetry
|
||||
python-version: "3.9"
|
||||
- name: Install pipenv
|
||||
shell: bash
|
||||
run: pip install --upgrade poetry
|
||||
run: pip install --upgrade pipenv
|
||||
|
||||
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -1,11 +0,0 @@
|
||||
# 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"
|
||||
79
.github/pull_request_template.md
vendored
79
.github/pull_request_template.md
vendored
@@ -1,79 +0,0 @@
|
||||
<!--
|
||||
Please follow the instructions found in this pull request template so that we
|
||||
have all of the relevant details needed for our work.
|
||||
|
||||
At the minimum, please be sure to fill in all sections found below and also do
|
||||
the following:
|
||||
|
||||
- Provide an appropriate and descriptive title for the pull request
|
||||
- Link the pull request to its corresponding issue (must be done after creating
|
||||
the pull request itself)
|
||||
- Assign yourself as the author
|
||||
- Attach the appropriate labels to it
|
||||
- Set it to be on the Notify.gov project board
|
||||
- Select one or more reviewers from the team or mark the pull request as a draft
|
||||
depending on its current state
|
||||
- If the pull request is a draft, please be sure to add reviewers once it is
|
||||
ready for review and mark it ready for review
|
||||
|
||||
For each section, please delete the instructions/sample text (that includes this
|
||||
text, though it is wrapped in an HTML comment just in case) and put in your own
|
||||
information. Thank you!
|
||||
-->
|
||||
|
||||
*A note to PR reviewers: it may be helpful to review our
|
||||
[code review documentation](https://github.com/GSA/notifications-api/blob/main/docs/all.md#code-reviews)
|
||||
to know what to keep in mind while reviewing pull requests.*
|
||||
|
||||
## Description
|
||||
|
||||
Please enter a clear description about your proposed changes and what the
|
||||
expected outcome(s) is/are from there. If there are complex implementation
|
||||
details within the changes, this is a great place to explain those details using
|
||||
plain language.
|
||||
|
||||
This should include:
|
||||
|
||||
- Links to issues that this PR addresses
|
||||
- Screenshots or screen captures of any visible changes, especially for UI work
|
||||
- Dependency changes
|
||||
|
||||
If there are any caveats, known issues, follow-up items, etc., make a quick note
|
||||
of them here as well, though more details are probably warranted in the issue
|
||||
itself in this case.
|
||||
|
||||
## TODO (optional)
|
||||
|
||||
If you're opening a draft PR, it might be helpful to list any outstanding work,
|
||||
especially if you're asking folks to take a look before it's ready for full
|
||||
review. In this case, create a small checklist with the outstanding items:
|
||||
|
||||
- [ ] TODO item 1
|
||||
- [ ] TODO item 2
|
||||
- [ ] TODO item ...
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Please think about the security compliance aspect of your changes and what the
|
||||
potential impacts might be.
|
||||
|
||||
**NOTE: Please be mindful of sharing sensitive information here! If you're not
|
||||
sure of what to write, please ask the team first before writing anything here.**
|
||||
|
||||
Relevant details could include (and are not limited to) the following:
|
||||
|
||||
- Handling secrets/credential management (or specifically calling out that there
|
||||
is nothing to handle)
|
||||
- Any adjustments to the flow of data in and out the system, or even within it
|
||||
- Connecting or disconnecting any external services to the application
|
||||
- Handling of any sensitive information, such as PII
|
||||
- Handling of information within log statements or other application monitoring
|
||||
services/hooks
|
||||
- The inclusion of a new external dependency or the removal of an existing one
|
||||
- ... (anything else relevant from a security compliance perspective)
|
||||
|
||||
There are some cases where there are no security considerations to be had, e.g.,
|
||||
updating our documentation with publicly available information. In those cases
|
||||
it is fine to simply put something like this:
|
||||
|
||||
- None; this is a documentation update with publicly available information.
|
||||
83
.github/workflows/adr-accepted.yml
vendored
83
.github/workflows/adr-accepted.yml
vendored
@@ -1,83 +0,0 @@
|
||||
name: ADR accepted
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- closed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
accept:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: check for tags
|
||||
if: "${{ !contains(github.event.issue.labels.*.name, 'ADR: accepted' )}}"
|
||||
shell: bash
|
||||
run: exit 0
|
||||
|
||||
- name: checkout main branch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: main
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: get ADR number
|
||||
id: next
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p docs/adrs
|
||||
LAST_ADR=$(ls docs/adrs/*.md | grep -Eo "/[0-9]+-" | sort | tail -n1 | grep -Eo "[0-9]+")
|
||||
LAST_ADR=$(echo "$LAST_ADR" | sed -E 's/^0+//')
|
||||
NEXT_ADR=$(($LAST_ADR + 1))
|
||||
NEXT_ADR=$(printf "%04i" "$NEXT_ADR")
|
||||
echo "number=$NEXT_ADR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: get date
|
||||
id: date
|
||||
shell: bash
|
||||
run: echo "date=$(date +'%B %d, %Y')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: build filename
|
||||
id: filename
|
||||
shell: bash
|
||||
run: |
|
||||
SLUG=$(printf '%q\n' "${{ github.event.issue.title }}" | tr A-Z a-z)
|
||||
SLUG=$(printf '%q\n' "$SLUG" | iconv -c -t ascii//TRANSLIT)
|
||||
SLUG=$(printf '%q\n' "$SLUG" | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/-+/-/g' | sed -E 's/^-+|-+$//g')
|
||||
|
||||
FILENAME="docs/adrs/${{ steps.next.outputs.number }}-$SLUG.md"
|
||||
echo "slug=$SLUG" >> "$GITHUB_OUTPUT"
|
||||
echo "filename=$FILENAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: write the ADR
|
||||
uses: DamianReeves/write-file-action@v1.2
|
||||
with:
|
||||
path: ${{ steps.filename.outputs.filename }}
|
||||
write-mode: overwrite
|
||||
contents: |
|
||||
# ${{ github.event.issue.title }}
|
||||
|
||||
Status: Accepted
|
||||
Date: ${{ steps.date.outputs.date }}
|
||||
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
- name: branch, commit, and open PR
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="adr/auto/${{ steps.filename.outputs.slug }}"
|
||||
git config --global user.email "tts@gsa.gov"
|
||||
git config --global user.name "Notify ADR Automation"
|
||||
git checkout -b $BRANCH
|
||||
git add docs/adrs/*.md
|
||||
git commit -m "add ADR ${{ steps.next.outputs.number }}: ${{ github.event.issue.title }}"
|
||||
git push -f origin $BRANCH
|
||||
gh pr create \
|
||||
--title "Add ADR ${{ steps.next.outputs.number }} to the repo" \
|
||||
--body "This pull request was opened automatically because #${{ github.event.issue.number }} was closed after being marked as an approved ADR. It contains a markdown file capturing the ADR body at the time the issue was closed. Please verify that the markdown is correct before merging!" || true
|
||||
gh pr merge $BRANCH --auto --squash || true
|
||||
46
.github/workflows/checks.yml
vendored
46
.github/workflows/checks.yml
vendored
@@ -13,7 +13,9 @@ env:
|
||||
FLASK_APP: application.py
|
||||
WERKZEUG_DEBUG_PIN: off
|
||||
REDIS_ENABLED: 0
|
||||
AWS_US_TOLL_FREE_NUMBER: "+18556438890"
|
||||
AWS_REGION: us-west-2
|
||||
AWS_PINPOINT_REGION: us-west-2
|
||||
AWS_US_TOLL_FREE_NUMBER: +18446120782
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -42,27 +44,14 @@ jobs:
|
||||
run: make bootstrap
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api
|
||||
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_USER: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_USER }}
|
||||
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
|
||||
- name: Run style checks
|
||||
run: poetry run flake8 .
|
||||
run: pipenv run flake8 .
|
||||
- name: Check imports alphabetized
|
||||
run: poetry run isort --check-only ./app ./tests
|
||||
- name: Check for dead code
|
||||
run: make dead-code
|
||||
- name: Run tests with coverage
|
||||
run: poetry run coverage run --omit=*/notifications_utils/* -m pytest --maxfail=10
|
||||
run: pipenv run isort --check-only ./app ./tests
|
||||
- name: Run tests
|
||||
run: pipenv run pytest -n4 --maxfail=10
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api
|
||||
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_USER: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_USER }}
|
||||
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
|
||||
- name: Check coverage threshold
|
||||
# TODO get this back up to 95
|
||||
run: poetry run coverage report --fail-under=87
|
||||
|
||||
validate-new-relic-config:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -70,14 +59,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Install poetry packages
|
||||
run: poetry install
|
||||
- name: Install pipenv packages
|
||||
run: pipenv install --dev
|
||||
- name: Validate NewRelic config
|
||||
env:
|
||||
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
|
||||
# Need to set a NEW_RELIC_ENVIRONMENT with monitor_mode: true
|
||||
NEW_RELIC_ENVIRONMENT: staging
|
||||
run: poetry run newrelic-admin validate-config $NEW_RELIC_CONFIG_FILE
|
||||
run: pipenv run newrelic-admin validate-config $NEW_RELIC_CONFIG_FILE
|
||||
|
||||
pip-audit:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -85,10 +74,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Create requirements.txt
|
||||
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
|
||||
- uses: pypa/gh-action-pip-audit@v1.0.6
|
||||
run: pipenv requirements > requirements.txt
|
||||
- uses: pypa/gh-action-pip-audit@v1.0.4
|
||||
with:
|
||||
inputs: requirements.txt
|
||||
ignore-vulns: PYSEC-2022-237
|
||||
|
||||
static-scan:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -124,18 +114,14 @@ jobs:
|
||||
run: make bootstrap
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api
|
||||
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_USER: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_USER }}
|
||||
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
|
||||
- name: Run server
|
||||
run: make run-flask &
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api
|
||||
- name: Run OWASP API Scan
|
||||
uses: zaproxy/action-api-scan@v0.5.0
|
||||
- name: Run OWASP Baseline Scan
|
||||
uses: zaproxy/action-api-scan@v0.1.1
|
||||
with:
|
||||
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
|
||||
docker_name: 'owasp/zap2docker-stable'
|
||||
target: 'http://localhost:6011/docs/openapi.yml'
|
||||
fail_action: true
|
||||
allow_issue_writing: false
|
||||
|
||||
32
.github/workflows/daily_checks.yml
vendored
32
.github/workflows/daily_checks.yml
vendored
@@ -16,8 +16,11 @@ env:
|
||||
NEW_RELIC_ENVIRONMENT: test
|
||||
FLASK_APP: application.py
|
||||
WERKZEUG_DEBUG_PIN: off
|
||||
NOTIFY_EMAIL_DOMAIN: dispostable.com
|
||||
REDIS_ENABLED: 0
|
||||
AWS_US_TOLL_FREE_NUMBER: "+18556438890"
|
||||
AWS_REGION: us-west-2
|
||||
AWS_PINPOINT_REGION: us-west-2
|
||||
AWS_US_TOLL_FREE_NUMBER: +18446120782
|
||||
|
||||
jobs:
|
||||
pip-audit:
|
||||
@@ -26,15 +29,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Create requirements.txt
|
||||
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
|
||||
- uses: pypa/gh-action-pip-audit@v1.0.6
|
||||
run: pipenv requirements > requirements.txt
|
||||
- uses: pypa/gh-action-pip-audit@v1.0.4
|
||||
with:
|
||||
inputs: requirements.txt
|
||||
- name: Upload pip-audit artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: pip-audit-report
|
||||
path: /tmp/pip-audit-output.txt
|
||||
ignore-vulns: PYSEC-2022-237
|
||||
|
||||
static-scan:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -44,12 +43,7 @@ jobs:
|
||||
- name: Install bandit
|
||||
run: pip install bandit
|
||||
- name: Run scan
|
||||
run: bandit -r app/ -f txt -o /tmp/bandit-output.txt --confidence-level medium
|
||||
- name: Upload bandit artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: bandit-report
|
||||
path: /tmp/bandit-output.txt
|
||||
run: bandit -r app/ --confidence-level medium
|
||||
|
||||
dynamic-scan:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -75,18 +69,14 @@ jobs:
|
||||
run: make bootstrap
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api
|
||||
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD }}
|
||||
NOTIFY_E2E_TEST_HTTP_AUTH_USER: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_USER }}
|
||||
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
|
||||
- name: Run server
|
||||
run: make run-flask &
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api
|
||||
- name: Run OWASP API Scan
|
||||
uses: zaproxy/action-api-scan@v0.5.0
|
||||
- name: Run OWASP Baseline Scan
|
||||
uses: zaproxy/action-api-scan@v0.1.1
|
||||
with:
|
||||
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
|
||||
docker_name: 'owasp/zap2docker-weekly'
|
||||
target: 'http://localhost:6011/docs/openapi.yml'
|
||||
fail_action: true
|
||||
allow_issue_writing: false
|
||||
|
||||
26
.github/workflows/deploy-demo.yml
vendored
26
.github/workflows/deploy-demo.yml
vendored
@@ -18,12 +18,9 @@ jobs:
|
||||
|
||||
- name: Check for changes to Terraform
|
||||
id: changed-terraform-files
|
||||
uses: tj-actions/changed-files@v41
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
terraform/demo
|
||||
terraform/shared
|
||||
.github/workflows/deploy-demo.yml
|
||||
files: terraform/demo
|
||||
- name: Terraform init
|
||||
if: steps.changed-terraform-files.outputs.any_changed == 'true'
|
||||
working-directory: terraform/demo
|
||||
@@ -45,8 +42,8 @@ jobs:
|
||||
- name: Install application dependencies
|
||||
run: make bootstrap
|
||||
|
||||
- name: Create requirements.txt
|
||||
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
|
||||
- name: Create requirements.txt because Cloud Foundry does a weird pipenv thing
|
||||
run: pipenv requirements > requirements.txt
|
||||
|
||||
- name: Deploy to cloud.gov
|
||||
uses: 18f/cg-deploy-action@main
|
||||
@@ -54,29 +51,26 @@ jobs:
|
||||
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
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 }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_org: gsa-tts-benefits-studio-prototyping
|
||||
cf_space: notify-demo
|
||||
push_arguments: >-
|
||||
--vars-file deploy-config/demo.yml
|
||||
--var DANGEROUS_SALT="$DANGEROUS_SALT"
|
||||
--var SECRET_KEY="$SECRET_KEY"
|
||||
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
|
||||
--var AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID"
|
||||
--var AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
|
||||
--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
|
||||
uses: tj-actions/changed-files@v41
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
deploy-config/egress_proxy/notify-api-demo.*.acl
|
||||
|
||||
94
.github/workflows/deploy-prod.yml
vendored
94
.github/workflows/deploy-prod.yml
vendored
@@ -1,94 +0,0 @@
|
||||
name: Deploy to production environment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ production ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# deploy-prod and deploy-demo will run in parallel now.
|
||||
# TODO: Research if we want to serialize them
|
||||
# by moving the jobs into a single file similar to
|
||||
# https://github.com/GSA/usnotify-ssb/blob/main/.github/workflows/apply.yml
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check for changes to Terraform
|
||||
id: changed-terraform-files
|
||||
uses: tj-actions/changed-files@v41
|
||||
with:
|
||||
files: |
|
||||
terraform/production
|
||||
terraform/shared
|
||||
.github/workflows/deploy-prod.yml
|
||||
- name: Terraform init
|
||||
if: steps.changed-terraform-files.outputs.any_changed == 'true'
|
||||
working-directory: terraform/production
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
|
||||
run: terraform init
|
||||
- name: Terraform apply
|
||||
if: steps.changed-terraform-files.outputs.any_changed == 'true'
|
||||
working-directory: terraform/production
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
|
||||
TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
run: terraform apply -auto-approve -input=false
|
||||
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Install application dependencies
|
||||
run: make bootstrap
|
||||
|
||||
- name: Create requirements.txt
|
||||
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
|
||||
|
||||
- name: Deploy to cloud.gov
|
||||
uses: 18f/cg-deploy-action@main
|
||||
env:
|
||||
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
|
||||
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 }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-production
|
||||
push_arguments: >-
|
||||
--vars-file deploy-config/production.yml
|
||||
--var DANGEROUS_SALT="$DANGEROUS_SALT"
|
||||
--var SECRET_KEY="$SECRET_KEY"
|
||||
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
|
||||
--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
|
||||
uses: tj-actions/changed-files@v41
|
||||
with:
|
||||
files: |
|
||||
deploy-config/egress_proxy/notify-api-production.*.acl
|
||||
.github/actions/deploy-proxy/action.yml
|
||||
.github/workflows/deploy-prod.yml
|
||||
- name: Deploy egress proxy
|
||||
if: steps.changed-egress-config.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/deploy-proxy
|
||||
with:
|
||||
cf_space: notify-production
|
||||
app: notify-api-production
|
||||
26
.github/workflows/deploy.yml
vendored
26
.github/workflows/deploy.yml
vendored
@@ -23,12 +23,9 @@ jobs:
|
||||
|
||||
- name: Check for changes to Terraform
|
||||
id: changed-terraform-files
|
||||
uses: tj-actions/changed-files@v41
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
terraform/staging
|
||||
terraform/shared
|
||||
.github/workflows/deploy.yml
|
||||
files: terraform/staging
|
||||
- name: Terraform init
|
||||
if: steps.changed-terraform-files.outputs.any_changed == 'true'
|
||||
working-directory: terraform/staging
|
||||
@@ -50,8 +47,8 @@ jobs:
|
||||
- name: Install application dependencies
|
||||
run: make bootstrap
|
||||
|
||||
- name: Create requirements.txt
|
||||
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
|
||||
- name: Create requirements.txt because Cloud Foundry does a weird pipenv thing
|
||||
run: pipenv requirements > requirements.txt
|
||||
|
||||
- name: Deploy to cloud.gov
|
||||
uses: 18f/cg-deploy-action@main
|
||||
@@ -59,29 +56,26 @@ jobs:
|
||||
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
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 }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_org: gsa-tts-benefits-studio-prototyping
|
||||
cf_space: notify-staging
|
||||
push_arguments: >-
|
||||
--vars-file deploy-config/staging.yml
|
||||
--var DANGEROUS_SALT="$DANGEROUS_SALT"
|
||||
--var SECRET_KEY="$SECRET_KEY"
|
||||
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
|
||||
--var AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID"
|
||||
--var AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
|
||||
--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
|
||||
uses: tj-actions/changed-files@v41
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
deploy-config/egress_proxy/notify-api-staging.*.acl
|
||||
|
||||
66
.github/workflows/drift.yml
vendored
66
.github/workflows/drift.yml
vendored
@@ -1,66 +0,0 @@
|
||||
name: Verify Infrastructure
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# cron format: 'minute hour dayofmonth month dayofweek'
|
||||
# this will run at noon UTC every day (7am EST / 8am EDT)
|
||||
- cron: '0 12 * * *'
|
||||
|
||||
jobs:
|
||||
check_staging_drift:
|
||||
runs-on: ubuntu-latest
|
||||
name: Check for drift of staging terraform configuration
|
||||
environment: staging
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Check for drift
|
||||
uses: dflook/terraform-check@v1
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
|
||||
TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
with:
|
||||
path: terraform/staging
|
||||
|
||||
check_demo_drift:
|
||||
runs-on: ubuntu-latest
|
||||
name: Check for drift of demo terraform configuration
|
||||
environment: demo
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: 'production'
|
||||
|
||||
- name: Check for drift
|
||||
uses: dflook/terraform-check@v1
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
|
||||
TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
with:
|
||||
path: terraform/demo
|
||||
|
||||
check_prod_drift:
|
||||
runs-on: ubuntu-latest
|
||||
name: Check for drift of production terraform configuration
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: 'production'
|
||||
|
||||
- name: Check for drift
|
||||
uses: dflook/terraform-check@v1
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
|
||||
TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
with:
|
||||
path: terraform/production
|
||||
36
.github/workflows/restage-apps.yml
vendored
36
.github/workflows/restage-apps.yml
vendored
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: Restage apps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Which environment needs to be restaged"
|
||||
required: true
|
||||
default: staging
|
||||
type: environment
|
||||
|
||||
jobs:
|
||||
restage_apps:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environment }}
|
||||
strategy:
|
||||
matrix:
|
||||
app: ["api", "admin"]
|
||||
steps:
|
||||
- name: Restage ${{matrix.app}}
|
||||
uses: 18f/cg-deploy-action@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-${{ inputs.environment }}
|
||||
full_command: "cf restage --strategy rolling notify-${{matrix.app}}-${{inputs.environment}}"
|
||||
- name: Restage ${{matrix.app}} egress
|
||||
uses: 18f/cg-deploy-action@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-${{ inputs.environment }}-egress
|
||||
full_command: "cf restage --strategy rolling egress-proxy-notify-${{matrix.app}}-${{inputs.environment}}"
|
||||
2
.github/workflows/terraform-production.yml
vendored
2
.github/workflows/terraform-production.yml
vendored
@@ -2,7 +2,7 @@ name: Run Terraform plan in production
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ production ]
|
||||
branches: [ production-disabled-for-now ]
|
||||
paths: [ 'terraform/**' ]
|
||||
|
||||
defaults:
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -44,7 +44,6 @@ pip-delete-this-directory.txt
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage_cache
|
||||
.coverage.*
|
||||
.cache
|
||||
.pytest_cache
|
||||
@@ -77,7 +76,6 @@ environment.sh
|
||||
varsfile
|
||||
|
||||
celerybeat-schedule
|
||||
celerybeat-schedule.db
|
||||
|
||||
# CloudFoundry
|
||||
.cf
|
||||
@@ -92,7 +90,3 @@ varsfile*
|
||||
secrets.auto.tfvars
|
||||
terraform.tfstate
|
||||
terraform.tfstate.backup
|
||||
|
||||
|
||||
# Pyenv
|
||||
.python-version
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v3.2.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: debug-statements
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
2
.profile
2
.profile
@@ -3,7 +3,5 @@
|
||||
# https://docs.cloudfoundry.org/devguide/deploy-apps/deploy-app.html#profile
|
||||
##
|
||||
|
||||
export http_proxy=$egress_proxy
|
||||
export https_proxy=$egress_proxy
|
||||
export NEW_RELIC_PROXY_HOST=$egress_proxy
|
||||
export no_proxy="apps.internal"
|
||||
|
||||
@@ -16,4 +16,4 @@ As part of a U.S. government agency, the General Services Administration (GSA)
|
||||
|
||||
This project is in the public domain within the United States, and applicable copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
|
||||
All contributions to this project will be released under the CC0 dedication. By submitting a pull request or issue, you are agreeing to comply with this waiver of copyright interest.
|
||||
All contributions to this project will be released under the CC0 dedication. By submitting a pull request or issue, you are agreeing to comply with this waiver of copyright interest.
|
||||
@@ -50,4 +50,4 @@ As stated in [CONTRIBUTING](CONTRIBUTING.md), all contributions to this project
|
||||
|
||||
## Trademark
|
||||
|
||||
GSA owns, reserves, and retains all rights, title, and interest in all trademarks owned by GSA. The User does not acquire any right or interest of any kind in any GSA trademark because of their use of the Modified Version or any of the modifications made to the Work by GSA.
|
||||
GSA owns, reserves, and retains all rights, title, and interest in all trademarks owned by GSA. The User does not acquire any right or interest of any kind in any GSA trademark because of their use of the Modified Version or any of the modifications made to the Work by GSA.
|
||||
72
Makefile
72
Makefile
@@ -12,12 +12,9 @@ GIT_COMMIT ?= $(shell git rev-parse HEAD)
|
||||
.PHONY: bootstrap
|
||||
bootstrap: ## Set up everything to run the app
|
||||
make generate-version-file
|
||||
poetry self update
|
||||
poetry self add poetry-dotenv-plugin
|
||||
poetry install --sync --no-root
|
||||
poetry run pre-commit install
|
||||
pipenv install --dev
|
||||
createdb notification_api || true
|
||||
(poetry run flask db upgrade) || true
|
||||
(pipenv run flask db upgrade) || true
|
||||
|
||||
.PHONY: bootstrap-with-docker
|
||||
bootstrap-with-docker: ## Build the image to run the app in Docker
|
||||
@@ -25,46 +22,27 @@ bootstrap-with-docker: ## Build the image to run the app in Docker
|
||||
|
||||
.PHONY: run-procfile
|
||||
run-procfile:
|
||||
poetry run honcho start -f Procfile.dev
|
||||
|
||||
.PHONY: avg-complexity
|
||||
avg-complexity:
|
||||
echo "*** Shows average complexity in radon of all code ***"
|
||||
poetry run radon cc ./app -a -na
|
||||
|
||||
.PHONY: too-complex
|
||||
too-complex:
|
||||
echo "*** Shows code that got a rating of C, D or F in radon ***"
|
||||
poetry run radon cc ./app -a -nc
|
||||
pipenv run honcho start -f Procfile.dev
|
||||
|
||||
.PHONY: run-flask
|
||||
run-flask: ## Run flask
|
||||
poetry run newrelic-admin run-program flask run -p 6011 --host=0.0.0.0
|
||||
pipenv run newrelic-admin run-program flask run -p 6011 --host=0.0.0.0
|
||||
|
||||
.PHONY: run-celery
|
||||
run-celery: ## Run celery, TODO remove purge for staging/prod
|
||||
poetry run celery -A run_celery.notify_celery purge -f
|
||||
poetry run newrelic-admin run-program celery \
|
||||
pipenv run celery -A run_celery.notify_celery purge -f
|
||||
pipenv run newrelic-admin run-program celery \
|
||||
-A run_celery.notify_celery worker \
|
||||
--pidfile="/tmp/celery.pid" \
|
||||
--loglevel=INFO \
|
||||
--concurrency=4
|
||||
|
||||
|
||||
.PHONY: dead-code
|
||||
dead-code:
|
||||
poetry run vulture ./app --min-confidence=100
|
||||
|
||||
.PHONY: run-celery-beat
|
||||
run-celery-beat: ## Run celery beat
|
||||
poetry run celery \
|
||||
pipenv run celery \
|
||||
-A run_celery.notify_celery beat \
|
||||
--loglevel=INFO
|
||||
|
||||
.PHONY: cloudgov-user-report
|
||||
cloudgov-user-report:
|
||||
@poetry run python -m terraform.ops.cloudgov_user_report
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -75,40 +53,26 @@ generate-version-file: ## Generates the app version file
|
||||
|
||||
.PHONY: test
|
||||
test: export NEW_RELIC_ENVIRONMENT=test
|
||||
test: ## Run tests and create coverage report
|
||||
poetry run black .
|
||||
poetry run flake8 .
|
||||
poetry run isort --check-only ./app ./tests
|
||||
poetry run coverage run --omit=*/notifications_utils/* -m pytest --maxfail=10
|
||||
poetry run coverage report -m --fail-under=95
|
||||
poetry run coverage html -d .coverage_cache
|
||||
|
||||
.PHONY: py-lock
|
||||
py-lock: ## Syncs dependencies and updates lock file without performing recursive internal updates
|
||||
poetry lock --no-update
|
||||
poetry install --sync
|
||||
|
||||
.PHONY: update-utils
|
||||
update-utils: ## Forces Poetry to pull the latest changes from the notifications-utils repo; requires that you commit the changes to poetry.lock!
|
||||
poetry update notifications-utils
|
||||
@echo
|
||||
@echo !!! PLEASE MAKE SURE TO COMMIT AND PUSH THE UPDATED poetry.lock FILE !!!
|
||||
@echo
|
||||
test: ## Run tests
|
||||
pipenv run flake8 .
|
||||
pipenv run isort --check-only ./app ./tests
|
||||
pipenv run pytest -n4 --maxfail=10
|
||||
|
||||
.PHONY: freeze-requirements
|
||||
freeze-requirements: ## Pin all requirements including sub dependencies into requirements.txt
|
||||
poetry export --without-hashes --format=requirements.txt > requirements.txt
|
||||
pipenv lock
|
||||
pipenv requirements
|
||||
|
||||
.PHONY: audit
|
||||
audit:
|
||||
poetry requirements > requirements.txt
|
||||
poetry requirements --dev > requirements_for_test.txt
|
||||
poetry run pip-audit -r requirements.txt
|
||||
poetry run pip-audit -r requirements_for_test.txt
|
||||
pipenv requirements > requirements.txt
|
||||
pipenv requirements --dev > requirements_for_test.txt
|
||||
pipenv run pip-audit -r requirements.txt --ignore-vuln PYSEC-2022-237
|
||||
-pipenv run pip-audit -r requirements_for_test.txt
|
||||
|
||||
.PHONY: static-scan
|
||||
static-scan:
|
||||
poetry run bandit -r app/
|
||||
pipenv run bandit -r app/
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
|
||||
83
Pipfile
Normal file
83
Pipfile
Normal file
@@ -0,0 +1,83 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
alembic = "==1.7.7"
|
||||
amqp = "==5.1.1"
|
||||
arrow = "==1.2.2"
|
||||
asn1crypto = "==1.5.1"
|
||||
async-timeout = "==4.0.2"
|
||||
attrs = "==21.4.0"
|
||||
awscli = "==1.24.8"
|
||||
bcrypt = "==3.2.2"
|
||||
beautifulsoup4 = "==4.11.1"
|
||||
billiard = "==3.6.4.0"
|
||||
bleach = "==4.1.0"
|
||||
blinker = "==1.4"
|
||||
boto3 = "==1.23.8"
|
||||
botocore = "==1.26.8"
|
||||
cachetools = "==5.1.0"
|
||||
celery = {version = "==5.2.7", extras = ["redis"]}
|
||||
certifi = ">=2022.12.7"
|
||||
cffi = "==1.15.0"
|
||||
charset-normalizer = "==2.0.12"
|
||||
click = "==8.1.3"
|
||||
click-datetime = "==0.2"
|
||||
click-didyoumean = "==0.3.0"
|
||||
click-plugins = "==1.1.1"
|
||||
click-repl = "==0.2.0"
|
||||
colorama = "==0.4.4"
|
||||
defusedxml = "==0.7.1"
|
||||
deprecated = "==1.2.13"
|
||||
dnspython = "==2.2.1"
|
||||
docopt = "==0.6.2"
|
||||
docutils = "==0.16"
|
||||
eventlet = "==0.33.1"
|
||||
flask = "~=2.2"
|
||||
flask-bcrypt = "==1.0.1"
|
||||
flask-marshmallow = "==0.14.0"
|
||||
flask-migrate = "==3.1.0"
|
||||
flask-redis = "==0.4.0"
|
||||
flask-sqlalchemy = {version = "==2.5.1", ref = "aa7a61a5357cf6f5dcc135d98c781192457aa6fa", git = "https://github.com/pallets-eco/flask-sqlalchemy.git"}
|
||||
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"]}
|
||||
lxml = "==4.9.1"
|
||||
marshmallow = "==3.15.0"
|
||||
marshmallow-sqlalchemy = "==0.28.1"
|
||||
notifications-python-client = "==6.3.0"
|
||||
oscrypto = "==1.3.0"
|
||||
psycopg2-binary = "==2.9.3"
|
||||
pyjwt = "==2.4.0"
|
||||
python-dotenv = "==0.20.0"
|
||||
sqlalchemy = "==1.4.40"
|
||||
werkzeug = "~=2.2"
|
||||
# gds metrics packages
|
||||
prometheus-client = "==0.14.1"
|
||||
gds-metrics = {version = "==0.2.4", ref = "6f1840a57b6fb1ee40b7e84f2f18ec229de8aa72", git = "https://github.com/alphagov/gds_metrics_python.git"}
|
||||
packaging = "==21.3"
|
||||
notifications-utils = {editable = true, ref = "main", git = "https://github.com/GSA/notifications-utils.git"}
|
||||
newrelic = "*"
|
||||
|
||||
[dev-packages]
|
||||
flake8 = "==4.0.1"
|
||||
flake8-bugbear = "==22.4.25"
|
||||
isort = "==5.10.1"
|
||||
moto = "==3.1.9"
|
||||
pytest = "==7.1.2"
|
||||
pytest-env = "==0.6.2"
|
||||
pytest-mock = "==3.7.0"
|
||||
pytest-cov = "==3.0.0"
|
||||
pytest-xdist = "==2.5.0"
|
||||
freezegun = "==1.2.1"
|
||||
requests-mock = "==1.9.3"
|
||||
jinja2-cli = {version = "==0.8.2", extras = ["yaml"]}
|
||||
pip-audit = "*"
|
||||
bandit = "*"
|
||||
honcho = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
||||
2256
Pipfile.lock
generated
Normal file
2256
Pipfile.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
543
README.md
543
README.md
@@ -1,20 +1,12 @@
|
||||

|
||||
# US Notify API
|
||||
|
||||
# Notify.gov API
|
||||
|
||||
This project is the core of [Notify.gov](https://notify-demo.app.cloud.gov).
|
||||
It's cloned from the brilliant work of the team at
|
||||
[GOV.UK Notify](https://github.com/alphagov/notifications-api), cheers!
|
||||
This project is the core of [Notify](https://notifications-admin.app.cloud.gov/). It's cloned from the brilliant work of the team at [GOV.UK Notify](https://github.com/alphagov/notifications-api), cheers!
|
||||
|
||||
This repo contains:
|
||||
|
||||
- A public-facing REST API for Notify.gov, which teams can integrate with using
|
||||
[API clients built by UK](https://www.notifications.service.gov.uk/documentation).
|
||||
- An internal-only REST API built using Flask to manage services, users,
|
||||
templates, etc., which the
|
||||
[Notify.gov Admin UI](http://github.com/18F/notifications-admin) talks to.
|
||||
- Asynchronous workers built using Celery to put things on queues and read them
|
||||
off to be processed, sent to providers, updated, etc.
|
||||
- A public-facing REST API for Notify, which teams can integrate with using [API clients built by UK](https://www.notifications.service.gov.uk/documentation)
|
||||
- An internal-only REST API built using Flask to manage services, users, templates, etc., which the [admin UI](http://github.com/18F/notifications-admin) talks to)
|
||||
- Asynchronous workers built using Celery to put things on queues and read them off to be processed, sent to providers, updated, etc.
|
||||
|
||||
Our other repositories are:
|
||||
|
||||
@@ -23,519 +15,100 @@ Our other repositories are:
|
||||
- [us-notify-compliance](https://github.com/GSA/us-notify-compliance/)
|
||||
- [notify-python-demo](https://github.com/GSA/notify-python-demo)
|
||||
|
||||
## Before You Start
|
||||
## Documentation, here and elsewhere
|
||||
|
||||
You will need the following items:
|
||||
|
||||
- An active cloud.gov account with the correct permissions - speak with your
|
||||
onboarding buddy for help with
|
||||
[setting up an account](https://cloud.gov/sign-up/) (requires a `.mil`,
|
||||
`.gov`, or `.fed.us` email address) and getting access to the
|
||||
`notify-local-dev` and `notify-staging` spaces.
|
||||
- Admin priviliges and SSH access on your machine; you may need to work with
|
||||
your organization's IT support staff if you're not sure or don't currently
|
||||
have this access.
|
||||
### About Notify
|
||||
|
||||
## Local Environment Setup
|
||||
- [Roadmap](https://notifications-admin.app.cloud.gov/features/roadmap)
|
||||
- [Using the API](./docs/api-usage.md)
|
||||
|
||||
This project currently works with these major versions of the following main
|
||||
components:
|
||||
### Infrastructure
|
||||
|
||||
- Python 3.12.x
|
||||
- PostgreSQL 15.x (version 12.x is used in the hosted environments)
|
||||
- [Overview, setup, and onboarding](./docs/infra-overview.md)
|
||||
- [Database management](./docs/database-management.md)
|
||||
- [Celery queues and tasks](./docs/queues-and-tasks.md)
|
||||
|
||||
These instructions will walk you through how to set your machine up with all of
|
||||
the required tools for this project.
|
||||
### Common dev work
|
||||
|
||||
### Project Pre-Requisite Setup
|
||||
- [Local setup](#local-setup)
|
||||
- [Testing](./docs/testing.md), both automated and manual
|
||||
- [Deploying](./docs/deploying.md)
|
||||
- [Running one-off tasks](./docs/one-off-tasks.md)
|
||||
|
||||
On MacOS, using [Homebrew](https://brew.sh/) for package management is highly
|
||||
recommended. This helps avoid some known installation issues. Start by following
|
||||
the installation instructions on the Homebrew homepage.
|
||||
## UK docs that may still be helpful
|
||||
|
||||
**Note:** You will also need Xcode or the Xcode Command Line Tools installed. The
|
||||
quickest way to do this is is by installing the command line tools in the shell:
|
||||
- [Writing public APIs](docs/writing-public-apis.md)
|
||||
- [Updating dependencies](https://github.com/alphagov/notifications-manuals/wiki/Dependencies)
|
||||
|
||||
```sh
|
||||
xcode-select –-install
|
||||
```
|
||||
## Local setup
|
||||
|
||||
#### Homebrew Setup
|
||||
### Direct installation
|
||||
|
||||
If this is your first time installing Homebrew on your machine, you may need to
|
||||
add its binaries to your system's `$PATH` environment variable so that you can
|
||||
use the `brew` command. Try running `brew help` to see if Homebrew is
|
||||
recognized and runs properly. If that fails, then you'll need to add a
|
||||
configuration line to wherever your `$PATH` environment variable is set.
|
||||
1. Set up Postgres && Redis on your machine
|
||||
|
||||
Your system `$PATH` environment variable is likely set in one of these
|
||||
locations:
|
||||
1. Install [pipenv](https://pipenv.pypa.io/en/latest/)
|
||||
|
||||
For BASH shells:
|
||||
- `~/.bashrc`
|
||||
- `~/.bash_profile`
|
||||
- `~/.profile`
|
||||
1. Install dependencies into a virtual environment
|
||||
|
||||
For ZSH shells:
|
||||
- `~/.zshrc`
|
||||
- `~/.zprofile`
|
||||
`make bootstrap`
|
||||
|
||||
There may be different files that you need to modify for other shell
|
||||
environments.
|
||||
1. Create the .env file
|
||||
|
||||
Which file you need to modify depends on whether or not you are running an
|
||||
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)
|
||||
for an explanation of the differences). If you're still not sure, please ask
|
||||
the team for help!
|
||||
```
|
||||
cp sample.env .env
|
||||
# follow the instructions in .env
|
||||
```
|
||||
|
||||
Once you determine which file you'll need to modify, add these lines before any
|
||||
lines that add or modify the `$PATH` environment variable; near or at the top
|
||||
of the file is appropriate:
|
||||
1. Run the web server and background worker
|
||||
|
||||
```sh
|
||||
# Homebrew setup
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
```
|
||||
`make run-procfile`
|
||||
|
||||
This will make sure Homebrew gets setup correctly. Once you make these changes,
|
||||
either start a new shell session or source the file
|
||||
(`source ~/.FILE-YOU-MODIFIED`) you modified to have your system recognize the
|
||||
changes.
|
||||
1. Or run them individually:
|
||||
|
||||
Verify that Homebrew is now working by trying to run `brew help` again.
|
||||
* Run Flask (web server)
|
||||
|
||||
### System-Level Package Installation
|
||||
`make run-flask`
|
||||
|
||||
There are several packages you will need to install for your system in order to
|
||||
get the app running (and these are good to have in general for any software
|
||||
development).
|
||||
* Run Celery (background worker)
|
||||
|
||||
Start off with these packages since they're quick and don't require additional
|
||||
configuration after installation to get working out of the box:
|
||||
`make run-celery`
|
||||
|
||||
- [jq](https://stedolan.github.io/jq/) - for working with JSON in the command
|
||||
line
|
||||
- [git](https://git-scm.com/) - for version control management
|
||||
- [tfenv](https://github.com/tfutils/tfenv) - for managing
|
||||
[Terraform](https://www.terraform.io/) installations
|
||||
- [cf-cli@8](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html) - for
|
||||
working with a Cloud Foundry platform (e.g., cloud.gov)
|
||||
- [redis](https://redis.io/) - required as the backend for the API's
|
||||
asynchronous job processing
|
||||
- [vim](https://www.vim.org/) - for editing files more easily in the command
|
||||
line
|
||||
- [wget](https://www.gnu.org/software/wget/) - for retrieving files in the
|
||||
command line
|
||||
|
||||
You can install them by running the following:
|
||||
### VS Code && Docker installation
|
||||
|
||||
```sh
|
||||
brew install jq git tfenv cloudfoundry/tap/cf-cli@8 redis vim wget
|
||||
```
|
||||
If you're working in VS Code, you can also leverage Docker for a containerized dev environment
|
||||
|
||||
#### Terraform Installation
|
||||
1. Create the .env file
|
||||
|
||||
As a part of the installation above, you just installed `tfenv` to manage
|
||||
Terraform installations. This is great, but you still need to install Terraform
|
||||
itself, which can be done with this command:
|
||||
```
|
||||
cp sample.env .env
|
||||
# follow the instructions in .env
|
||||
```
|
||||
|
||||
```sh
|
||||
tfenv install latest:^1.4.0
|
||||
```
|
||||
1. Install the Remote-Containers plug-in in VS Code
|
||||
|
||||
_NOTE: This project currently uses the latest `1.4.x release of Terraform._
|
||||
1. With Docker running, create the network:
|
||||
|
||||
#### Python Installation
|
||||
`docker network create notify-network`
|
||||
|
||||
Now we're going to install a tool to help us manage Python versions and
|
||||
virtual environments on our system. First, we'll install
|
||||
[pyenv](https://github.com/pyenv/pyenv) and one of its plugins,
|
||||
[pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv), with Homebrew:
|
||||
1. Using the command palette (shift+cmd+p) or green button thingy in the bottom left, search and select “Remote Containers: Open Folder in Container...” When prompted, choose **devcontainer-api** folder (note: this is a *subfolder* of notifications-api). This will start the container in a new window, replacing the current one.
|
||||
|
||||
```sh
|
||||
brew install pyenv pyenv-virtualenv
|
||||
```
|
||||
1. Wait a few minutes while things happen 🍵
|
||||
|
||||
When these finish installing, you'll need to make another adjustment in the
|
||||
file that you adjusted for your `$PATH` environment variable and Homebrew's
|
||||
setup. Open the file, and add these lines to it:
|
||||
1. Open a VS Code terminal and run the Flask application:
|
||||
|
||||
```
|
||||
# pyenv setup
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
eval "$(pyenv virtualenv-init -)"
|
||||
```
|
||||
`make run-flask`
|
||||
|
||||
Once again, start a new shell session or source the file in your current shell
|
||||
session to make the changes take effect.
|
||||
1. Open another VS Code terminal and run Celery:
|
||||
|
||||
Now we're ready to install the Python version we need with `pyenv`, like so:
|
||||
`make run-celery`
|
||||
|
||||
```sh
|
||||
pyenv install 3.12
|
||||
```
|
||||
|
||||
This will install the latest version of Python 3.12.
|
||||
|
||||
_NOTE: This project currently runs on Python 3.12.x._
|
||||
|
||||
#### Python Dependency Installation
|
||||
|
||||
Lastly, we need to install the tool we use to manage Python dependencies within
|
||||
the project, which is [poetry](https://python-poetry.org/).
|
||||
|
||||
Visit the
|
||||
[official installer instructions page](https://python-poetry.org/docs/#installing-with-the-official-installer)
|
||||
and follow the steps to install Poetry directly with the script.
|
||||
|
||||
This will ensure `poetry` doesn't conflict with any project virtual environments
|
||||
and can update itself properly.
|
||||
|
||||
#### PostgreSQL installation
|
||||
|
||||
We now need to install a database - this project uses PostgreSQL, and Homebrew
|
||||
requires a version number to be included with it when installing it:
|
||||
|
||||
```sh
|
||||
brew install postgresql@15
|
||||
```
|
||||
|
||||
_NOTE: This project currently works with PostgreSQL version 15.x; version 12.x is currently used in our hosted environments._
|
||||
|
||||
_NOTE: If you have a pre-existing instance of PSQL installed because of another product like PGAdmin, your database configuration may differ from the instructions above, which uses Homebrew to install and configure PostgreSQL. If this is the case for you, you may have to either account for slightly different user permissions with the database, or uninstall PGAdmin and/or PostgreSQL itself, and reinstall it with Homebrew to follow the steps above._
|
||||
|
||||
You'll now need to modify (or create, if it doesn't already exist) the `$PATH`
|
||||
environment variable to include the PostgreSQL binaries. Open the file you have
|
||||
worked with before to adjust your shell environment with the previous steps and
|
||||
do one of the following:
|
||||
|
||||
If you already have a line that modifies the `$PATH` environment variable, just
|
||||
add this path into the existing string:
|
||||
|
||||
```
|
||||
/opt/homebrew/opt/postgresql@15/bin
|
||||
```
|
||||
|
||||
If you don't have a line for your `$PATH` environment variable, add it in like
|
||||
this, which will include the PostgreSQL binaries:
|
||||
|
||||
```
|
||||
export PATH="/opt/homebrew/opt/postgresql@15/bin:$PATH
|
||||
```
|
||||
|
||||
_NOTE: You don't want to overwrite your existing `$PATH` environment variable! Hence the reason why it is included on the end like this; paths are separated by a colon._
|
||||
|
||||
#### Starting PostgreSQL and Redis
|
||||
|
||||
With both PostgreSQL and Redis installed, you now need to start the services.
|
||||
Run this command so that they're available at all times going forward on your
|
||||
machine:
|
||||
|
||||
```sh
|
||||
brew services start postgresql@15
|
||||
brew services start redis
|
||||
```
|
||||
|
||||
If they're already running, you can run this command instead to make sure the
|
||||
latest updates are applied to both services:
|
||||
|
||||
```sh
|
||||
brew services restart postgresql@15
|
||||
brew services restart redis
|
||||
```
|
||||
|
||||
### First-Time Project Setup
|
||||
|
||||
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 repository in the directory of your choosing on your machine:
|
||||
|
||||
```sh
|
||||
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.12.2` is what is installed on your
|
||||
machine):
|
||||
|
||||
```sh
|
||||
cd notifications-api
|
||||
pyenv virtualenv 3.12.2 notify-api
|
||||
pyenv local notify-api
|
||||
```
|
||||
|
||||
_If you're not sure which version of Python was installed with `pyenv`, you can check by running `pyenv versions` and it'll list everything available currently._
|
||||
|
||||
Now [log into cloud.gov](https://cloud.gov/docs/getting-started/setup/#set-up-the-command-line)
|
||||
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_
|
||||
|
||||
Now run the development Terraform setup by navigating to the development
|
||||
folder and running the script in it:
|
||||
|
||||
```sh
|
||||
cd terraform/development
|
||||
./run.sh
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
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
|
||||
project has support for end-to-end (E2E) tests and has some additional checks
|
||||
for the presence of an E2E test user so that it can be authenticated properly.
|
||||
|
||||
In the `.env` file, you should see this section:
|
||||
|
||||
```
|
||||
#############################################################
|
||||
|
||||
# E2E Testing
|
||||
|
||||
NOTIFY_E2E_TEST_EMAIL=example@fake.gov
|
||||
NOTIFY_E2E_TEST_PASSWORD="don't write secrets to the sample file"
|
||||
```
|
||||
|
||||
You can leave the email address alone or change it to something else to your
|
||||
liking.
|
||||
|
||||
**You should absolutely change the `NOTIFY_E2E_TEST_PASSWORD` environment
|
||||
variable to something else, preferably a lengthy passphrase.**
|
||||
|
||||
With those two environment variable set, the database migrations will run
|
||||
properly and an E2E test user will be ready to go for use in the admin project.
|
||||
|
||||
_Note: Whatever you set these two environment variables to, you'll need to
|
||||
match their values on the admin side. Please see the admin README and
|
||||
documentation for more details._
|
||||
|
||||
## Running the Project and Routine Maintenance
|
||||
|
||||
The first time you run the project you'll need to run the project setup from the
|
||||
root project directory:
|
||||
|
||||
```sh
|
||||
make bootstrap
|
||||
```
|
||||
|
||||
This command is handled by the `Makefile` file in the root project directory, as
|
||||
are a few others.
|
||||
|
||||
_NOTE: You'll want to occasionally run `make bootstrap` to keep your project up-to-date, especially when there are dependency updates._
|
||||
|
||||
Now you can run the web server and background workers for asynchronous jobs:
|
||||
|
||||
```sh
|
||||
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
|
||||
one separately:
|
||||
|
||||
- `make run-celery` - Handles the asynchronous jobs
|
||||
- `make run-celery-beat` - Handles the scheduling of asynchronous jobs
|
||||
- `make run-flask` - Runs the web server
|
||||
|
||||
## Python Dependency Management
|
||||
|
||||
We're using [`Poetry`](https://python-poetry.org/) for managing our Python
|
||||
dependencies and local virtual environments. When it comes to managing the
|
||||
Python dependencies, there are a couple of things to bear in mind.
|
||||
|
||||
For situations where you manually manipulate the `pyproject.toml` file, you
|
||||
should use the `make py-lock` command to sync the `poetry.lock` file. This will
|
||||
ensure that you don't inadvertently bring in other transitive dependency updates
|
||||
that have not been fully tested with the project yet.
|
||||
|
||||
If you're just trying to update a dependency to a newer (or the latest) version,
|
||||
you should let Poetry take care of that for you by running the following:
|
||||
|
||||
```sh
|
||||
poetry update <dependency> [<dependency>...]
|
||||
```
|
||||
|
||||
You can specify more than one dependency together. With this command, Poetry
|
||||
will do the following for you:
|
||||
|
||||
- Find the latest compatible version(s) of the specified dependency/dependencies
|
||||
- Install the new versions
|
||||
- Update and sync the `poetry.lock` file
|
||||
|
||||
In either situation, once you are finished and have verified the dependency
|
||||
changes are working, please be sure to commit both the `pyproject.toml` and
|
||||
`poetry.lock` files.
|
||||
|
||||
### Keeping the notification-utils Dependency Up-to-Date
|
||||
|
||||
The `notifications-utils` dependency references the other repository we have at
|
||||
https://github.com/GSA/notifications-utils - this dependency requires a bit of
|
||||
extra legwork to ensure it stays up-to-date.
|
||||
|
||||
Whenever a PR is merged in the `notifications-utils` repository, we need to make
|
||||
sure the changes are pulled in here and committed to this repository as well.
|
||||
You can do this by going through these steps:
|
||||
|
||||
- Make sure your local `main` branch is up-to-date
|
||||
- Create a new branch to work in
|
||||
- Run `make update-utils`
|
||||
- Commit the updated `poetry.lock` file and push the changes
|
||||
- Make a new PR with the change
|
||||
- Have the PR get reviewed and merged
|
||||
|
||||
## Known Installation Issues
|
||||
|
||||
### Python Installation Errors
|
||||
|
||||
On M1 Macs, if you get a `fatal error: 'Python.h' file not found` message, try a
|
||||
different method of installing Python. The recommended approach is to use
|
||||
[`pyenv`](https://github.com/pyenv/pyenv), as noted above in the installation
|
||||
instructions.
|
||||
|
||||
If you're using PyCharm for Python development, we've noticed some quirkiness
|
||||
with the IDE and the interaction between Poetry and virtual environment
|
||||
management that could cause a variety of problems to come up during project
|
||||
setup and dependency management. Other tools, such as Visual Studio Code, have
|
||||
proven to be a smoother experience for folks.
|
||||
|
||||
### PostgreSQL Installation Errors
|
||||
|
||||
A direct installation of PostgreSQL will not put the `createdb` command on your
|
||||
`$PATH`. It can be added there in your shell startup script, or a
|
||||
Homebrew-managed installation of PostgreSQL will take care of it. See the
|
||||
instructions above for more details.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Infrastructure overview](./docs/all.md#infrastructure-overview)
|
||||
- [GitHub Repositories](./docs/all.md#github-repositories)
|
||||
- [Terraform](./docs/all.md#terraform)
|
||||
- [AWS](./docs/all.md#aws)
|
||||
- [New Relic](./docs/all.md#new-relic)
|
||||
- [Onboarding](./docs/all.md#onboarding)
|
||||
- [Setting up the infrastructure](./docs/all.md#setting-up-the-infrastructure)
|
||||
- [Using the logs](./docs/all.md#using-the-logs)
|
||||
- [Testing](./docs/all.md#testing)
|
||||
- [CI testing](./docs/all.md#ci-testing)
|
||||
- [Manual testing](./docs/all.md#manual-testing)
|
||||
- [To run a local OWASP scan](./docs/all.md#to-run-a-local-owasp-scan)
|
||||
- [End-to-end testing](./docs/all.md#end-to-end-testing)
|
||||
- [Deploying](./docs/all.md#deploying)
|
||||
- [Egress Proxy](./docs/all.md#egress-proxy)
|
||||
- [Managing environment variables](./docs/all.md#managing-environment-variables)
|
||||
- [Managing application initialization](./docs/all.md#managing-application-initialization)
|
||||
- [Sandbox environment](./docs/all.md#sandbox-environment)
|
||||
- [Database management](./docs/all.md#database-management)
|
||||
- [Initial state](./docs/all.md#initial-state)
|
||||
- [Data Model Diagram](./docs/all.md#data-model-diagram)
|
||||
- [Migrations](./docs/all.md#migrations)
|
||||
- [Purging user data](./docs/all.md#purging-user-data)
|
||||
- [One-off tasks](./docs/all.md#one-off-tasks)
|
||||
- [Test Loading Commands](./docs/all.md#commands-for-test-loading-the-local-dev-database)
|
||||
- [How messages are queued and sent](./docs/all.md#how-messages-are-queued-and-sent)
|
||||
- [Writing public APIs](./docs/all.md#writing-public-apis)
|
||||
- [Overview](./docs/all.md#overview)
|
||||
- [Documenting APIs](./docs/all.md#documenting-apis)
|
||||
- [New APIs](./docs/all.md#new-apis)
|
||||
- [API Usage](./docs/all.md#api-usage)
|
||||
- [Connecting to the API](./docs/all.md#connecting-to-the-api)
|
||||
- [Postman Documentation](./docs/all.md#postman-documentation)
|
||||
- [Using OpenAPI documentation](./docs/all.md#using-openapi-documentation)
|
||||
- [Queues and tasks](./docs/all.md#queues-and-tasks)
|
||||
- [Priority queue](./docs/all.md#priority-queue)
|
||||
- [Celery scheduled tasks](./docs/all.md#celery-scheduled-tasks)
|
||||
- [Notify.gov](./docs/all.md#notifygov)
|
||||
- [System Description](./docs/all.md#system-description)
|
||||
- [Run Book](./docs/all.md#run-book)
|
||||
- [ Alerts, Notifications, Monitoring](./docs/all.md#-alerts-notifications-monitoring)
|
||||
- [ Restaging Apps](./docs/all.md#-restaging-apps)
|
||||
- [ Smoke-testing the App](./docs/all.md#-smoke-testing-the-app)
|
||||
- [ Configuration Management](./docs/all.md#-configuration-management)
|
||||
- [ DNS Changes](./docs/all.md#-dns-changes)
|
||||
- [Exporting test results for compliance monitoring](./docs/all.md#exporting-test-results-for-compliance-monitoring)
|
||||
- [ Known Gotchas](./docs/all.md#-known-gotchas)
|
||||
- [ User Account Management](./docs/all.md#-user-account-management)
|
||||
- [ SMS Phone Number Management](./docs/all.md#-sms-phone-number-management)
|
||||
- [Data Storage Policies \& Procedures](./docs/all.md#data-storage-policies--procedures)
|
||||
- [Potential PII Locations](./docs/all.md#potential-pii-locations)
|
||||
- [Data Retention Policy](./docs/all.md#data-retention-policy)
|
||||
NOTE: when you change .env in the future, you'll need to rebuild the devcontainer for the change to take effect. VS Code _should_ detect the change and prompt you with a toast notification during a cached build. If not, you can find a manual rebuild in command pallette or just `docker rm` the notifications-api container.
|
||||
|
||||
## License && public domain
|
||||
|
||||
Work through
|
||||
[commit `e604385`](https://github.com/GSA/notifications-api/commit/e604385e0cf4c2ab8c6451b7120ceb196cce21b5)
|
||||
is licensed by the UK government under the MIT license. Work after that commit
|
||||
is in the worldwide public domain. See [LICENSE.md](./LICENSE.md) for more
|
||||
information.
|
||||
Work through [commit `e604385`](https://github.com/GSA/notifications-api/commit/e604385e0cf4c2ab8c6451b7120ceb196cce21b5) is licensed by the UK government under the MIT license. Work after that commit is in the worldwide public domain. See [LICENSE.md](./LICENSE.md) for more information.
|
||||
|
||||
## Contributing
|
||||
|
||||
As stated in [CONTRIBUTING.md](CONTRIBUTING.md), all contributions to this
|
||||
project will be released under the CC0 dedication. By submitting a pull request,
|
||||
you are agreeing to comply with this waiver of copyright interest.
|
||||
As stated in [CONTRIBUTING.md](CONTRIBUTING.md), all contributions to this project will be released under the CC0 dedication. By submitting a pull request, you are agreeing to comply with this waiver of copyright interest.
|
||||
|
||||
304
app/__init__.py
304
app/__init__.py
@@ -3,59 +3,48 @@ import secrets
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from time import monotonic
|
||||
|
||||
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 celery import current_task
|
||||
from flask import (
|
||||
current_app,
|
||||
g,
|
||||
has_request_context,
|
||||
jsonify,
|
||||
make_response,
|
||||
request,
|
||||
)
|
||||
from flask_marshmallow import Marshmallow
|
||||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy as _SQLAlchemy
|
||||
from gds_metrics import GDSMetrics
|
||||
from gds_metrics.metrics import Gauge, Histogram
|
||||
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.statsd.statsd_client import StatsdClient
|
||||
from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient
|
||||
from sqlalchemy import event
|
||||
from werkzeug.exceptions import HTTPException as WerkzeugHTTPException
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from app.clients import NotificationProviderClients
|
||||
from app.clients.cloudwatch.aws_cloudwatch import AwsCloudwatchClient
|
||||
from app.clients.document_download import DocumentDownloadClient
|
||||
from app.clients.email.aws_ses import AwsSesClient
|
||||
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"""
|
||||
|
||||
def apply_driver_hacks(self, app, info, options):
|
||||
sa_url, options = super().apply_driver_hacks(app, info, options)
|
||||
if "connect_args" not in options:
|
||||
options["connect_args"] = {}
|
||||
options["connect_args"]["options"] = "-c statement_timeout={}".format(
|
||||
int(app.config["SQLALCHEMY_STATEMENT_TIMEOUT"]) * 1000
|
||||
if 'connect_args' not in options:
|
||||
options['connect_args'] = {}
|
||||
options['connect_args']["options"] = "-c statement_timeout={}".format(
|
||||
int(app.config['SQLALCHEMY_STATEMENT_TIMEOUT']) * 1000
|
||||
)
|
||||
return (sa_url, options)
|
||||
|
||||
@@ -67,48 +56,55 @@ notify_celery = NotifyCelery()
|
||||
aws_ses_client = AwsSesClient()
|
||||
aws_ses_stub_client = AwsSesStubClient()
|
||||
aws_sns_client = AwsSnsClient()
|
||||
aws_cloudwatch_client = AwsCloudwatchClient()
|
||||
encryption = Encryption()
|
||||
zendesk_client = ZendeskClient()
|
||||
statsd_client = StatsdClient()
|
||||
redis_store = RedisClient()
|
||||
document_download_client = DocumentDownloadClient()
|
||||
|
||||
metrics = GDSMetrics()
|
||||
|
||||
notification_provider_clients = NotificationProviderClients()
|
||||
|
||||
api_user = LocalProxy(lambda: g.api_user)
|
||||
authenticated_service = LocalProxy(lambda: g.authenticated_service)
|
||||
|
||||
CONCURRENT_REQUESTS = Gauge(
|
||||
'concurrent_web_request_count',
|
||||
'How many concurrent requests are currently being served',
|
||||
)
|
||||
|
||||
|
||||
def create_app(application):
|
||||
from app.config import configs
|
||||
|
||||
notify_environment = os.environ["NOTIFY_ENVIRONMENT"]
|
||||
notify_environment = os.environ['NOTIFY_ENVIRONMENT']
|
||||
|
||||
application.config.from_object(configs[notify_environment])
|
||||
|
||||
application.config["NOTIFY_APP_NAME"] = application.name
|
||||
application.config['NOTIFY_APP_NAME'] = application.name
|
||||
init_app(application)
|
||||
|
||||
# Metrics intentionally high up to give the most accurate timing and reliability that the metric is recorded
|
||||
metrics.init_app(application)
|
||||
request_helper.init_app(application)
|
||||
db.init_app(application)
|
||||
migrate.init_app(application, db=db)
|
||||
ma.init_app(application)
|
||||
zendesk_client.init_app(application)
|
||||
statsd_client.init_app(application)
|
||||
logging.init_app(application)
|
||||
aws_sns_client.init_app(application)
|
||||
aws_sns_client.init_app(application, statsd_client=statsd_client)
|
||||
|
||||
aws_ses_client.init_app()
|
||||
aws_ses_stub_client.init_app(stub_url=application.config["SES_STUB_URL"])
|
||||
aws_cloudwatch_client.init_app(application)
|
||||
# If a stub url is provided for SES, then use the stub client rather than the real SES boto client
|
||||
email_clients = (
|
||||
[aws_ses_stub_client]
|
||||
if application.config["SES_STUB_URL"]
|
||||
else [aws_ses_client]
|
||||
aws_ses_client.init_app(statsd_client=statsd_client)
|
||||
aws_ses_stub_client.init_app(
|
||||
statsd_client=statsd_client,
|
||||
stub_url=application.config['SES_STUB_URL']
|
||||
)
|
||||
# If a stub url is provided for SES, then use the stub client rather than the real SES boto client
|
||||
email_clients = [aws_ses_stub_client] if application.config['SES_STUB_URL'] else [aws_ses_client]
|
||||
notification_provider_clients.init_app(
|
||||
sms_clients=[aws_sns_client], email_clients=email_clients
|
||||
sms_clients=[aws_sns_client],
|
||||
email_clients=email_clients
|
||||
)
|
||||
|
||||
notify_celery.init_app(application)
|
||||
@@ -121,7 +117,6 @@ def create_app(application):
|
||||
|
||||
# avoid circular imports by importing this file later
|
||||
from app.commands import setup_commands
|
||||
|
||||
setup_commands(application)
|
||||
|
||||
# set up sqlalchemy events
|
||||
@@ -144,17 +139,25 @@ def register_blueprint(application):
|
||||
from app.inbound_number.rest import inbound_number_blueprint
|
||||
from app.inbound_sms.rest import inbound_sms as inbound_sms_blueprint
|
||||
from app.job.rest import job_blueprint
|
||||
from app.notifications.notifications_ses_callback import ses_callback_blueprint
|
||||
from app.notifications.receive_notifications import receive_notifications_blueprint
|
||||
from app.notifications.notifications_ses_callback import (
|
||||
ses_callback_blueprint,
|
||||
)
|
||||
from app.notifications.receive_notifications import (
|
||||
receive_notifications_blueprint,
|
||||
)
|
||||
from app.notifications.rest import notifications as notifications_blueprint
|
||||
from app.organization.invite_rest import organization_invite_blueprint
|
||||
from app.organization.rest import organization_blueprint
|
||||
from app.organisation.invite_rest import organisation_invite_blueprint
|
||||
from app.organisation.rest import organisation_blueprint
|
||||
from app.performance_dashboard.rest import performance_dashboard_blueprint
|
||||
from app.platform_stats.rest import platform_stats_blueprint
|
||||
from app.provider_details.rest import provider_details as provider_details_blueprint
|
||||
from app.provider_details.rest import (
|
||||
provider_details as provider_details_blueprint,
|
||||
)
|
||||
from app.service.callback_rest import service_callback_blueprint
|
||||
from app.service.rest import service_blueprint
|
||||
from app.service_invite.rest import service_invite as service_invite_blueprint
|
||||
from app.service_invite.rest import (
|
||||
service_invite as service_invite_blueprint,
|
||||
)
|
||||
from app.status.healthcheck import status as status_blueprint
|
||||
from app.template.rest import template_blueprint
|
||||
from app.template_folder.rest import template_folder_blueprint
|
||||
@@ -166,10 +169,10 @@ def register_blueprint(application):
|
||||
from app.webauthn.rest import webauthn_blueprint
|
||||
|
||||
service_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(service_blueprint, url_prefix="/service")
|
||||
application.register_blueprint(service_blueprint, url_prefix='/service')
|
||||
|
||||
user_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(user_blueprint, url_prefix="/user")
|
||||
application.register_blueprint(user_blueprint, url_prefix='/user')
|
||||
|
||||
webauthn_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(webauthn_blueprint)
|
||||
@@ -200,8 +203,8 @@ def register_blueprint(application):
|
||||
service_invite_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(service_invite_blueprint)
|
||||
|
||||
organization_invite_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(organization_invite_blueprint)
|
||||
organisation_invite_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(organisation_invite_blueprint)
|
||||
|
||||
inbound_number_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(inbound_number_blueprint)
|
||||
@@ -216,14 +219,10 @@ def register_blueprint(application):
|
||||
application.register_blueprint(events_blueprint)
|
||||
|
||||
provider_details_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(
|
||||
provider_details_blueprint, url_prefix="/provider-details"
|
||||
)
|
||||
application.register_blueprint(provider_details_blueprint, url_prefix='/provider-details')
|
||||
|
||||
email_branding_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(
|
||||
email_branding_blueprint, url_prefix="/email-branding"
|
||||
)
|
||||
application.register_blueprint(email_branding_blueprint, url_prefix='/email-branding')
|
||||
|
||||
billing_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(billing_blueprint)
|
||||
@@ -231,8 +230,8 @@ def register_blueprint(application):
|
||||
service_callback_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(service_callback_blueprint)
|
||||
|
||||
organization_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(organization_blueprint, url_prefix="/organizations")
|
||||
organisation_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(organisation_blueprint, url_prefix='/organisations')
|
||||
|
||||
complaint_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(complaint_blueprint)
|
||||
@@ -241,9 +240,7 @@ def register_blueprint(application):
|
||||
application.register_blueprint(performance_dashboard_blueprint)
|
||||
|
||||
platform_stats_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(
|
||||
platform_stats_blueprint, url_prefix="/platform-stats"
|
||||
)
|
||||
application.register_blueprint(platform_stats_blueprint, url_prefix='/platform-stats')
|
||||
|
||||
template_folder_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(template_folder_blueprint)
|
||||
@@ -281,44 +278,43 @@ def register_v2_blueprints(application):
|
||||
|
||||
|
||||
def init_app(app):
|
||||
|
||||
@app.before_request
|
||||
def record_request_details():
|
||||
CONCURRENT_REQUESTS.inc()
|
||||
|
||||
g.start = monotonic()
|
||||
g.endpoint = request.endpoint
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
response.headers.add("X-Content-Type-Options", "nosniff")
|
||||
CONCURRENT_REQUESTS.dec()
|
||||
|
||||
response.headers.add('Access-Control-Allow-Origin', '*')
|
||||
response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
|
||||
return response
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def exception(error):
|
||||
app.logger.exception(error)
|
||||
# error.code is set for our exception types.
|
||||
msg = getattr(error, "message", str(error))
|
||||
code = getattr(error, "code", 500)
|
||||
response = make_response(
|
||||
jsonify(result="error", message=msg), code, error.get_headers()
|
||||
)
|
||||
response.content_type = "application/json"
|
||||
return response
|
||||
msg = getattr(error, 'message', str(error))
|
||||
code = getattr(error, 'code', 500)
|
||||
return jsonify(result='error', message=msg), code
|
||||
|
||||
@app.errorhandler(WerkzeugHTTPException)
|
||||
def werkzeug_exception(e):
|
||||
response = make_response(
|
||||
jsonify(result="error", message=e.description), e.code, e.get_headers()
|
||||
return make_response(
|
||||
jsonify(result='error', message=e.description),
|
||||
e.code,
|
||||
e.get_headers()
|
||||
)
|
||||
response.content_type = "application/json"
|
||||
return response
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
msg = e.description or "Not found"
|
||||
response = make_response(
|
||||
jsonify(result="error", message=msg), 404, e.get_headers()
|
||||
)
|
||||
response.content_type = "application/json"
|
||||
return response
|
||||
return jsonify(result='error', message=msg), 404
|
||||
|
||||
|
||||
def create_uuid():
|
||||
@@ -326,29 +322,47 @@ def create_uuid():
|
||||
|
||||
|
||||
def create_random_identifier():
|
||||
return "".join(
|
||||
secrets.choice(string.ascii_uppercase + string.digits) for _ in range(16)
|
||||
return ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(16))
|
||||
|
||||
|
||||
def setup_sqlalchemy_events(app):
|
||||
|
||||
TOTAL_DB_CONNECTIONS = Gauge(
|
||||
'db_connection_total_connected',
|
||||
'How many db connections are currently held (potentially idle) by the server',
|
||||
)
|
||||
|
||||
TOTAL_CHECKED_OUT_DB_CONNECTIONS = Gauge(
|
||||
'db_connection_total_checked_out',
|
||||
'How many db connections are currently checked out by web requests',
|
||||
)
|
||||
|
||||
DB_CONNECTION_OPEN_DURATION_SECONDS = Histogram(
|
||||
'db_connection_open_duration_seconds',
|
||||
'How long db connections are held open for in seconds',
|
||||
['method', 'host', 'path']
|
||||
)
|
||||
|
||||
# TODO maintainability what is the purpose of this? Debugging?
|
||||
def setup_sqlalchemy_events(app):
|
||||
# need this or db.engine isn't accessible
|
||||
with app.app_context():
|
||||
@event.listens_for(db.engine, 'connect')
|
||||
def connect(dbapi_connection, connection_record):
|
||||
# connection first opened with db
|
||||
TOTAL_DB_CONNECTIONS.inc()
|
||||
|
||||
@event.listens_for(db.engine, "connect")
|
||||
def connect(dbapi_connection, connection_record): # noqa
|
||||
pass
|
||||
@event.listens_for(db.engine, 'close')
|
||||
def close(dbapi_connection, connection_record):
|
||||
# connection closed (probably only happens with overflow connections)
|
||||
TOTAL_DB_CONNECTIONS.dec()
|
||||
|
||||
@event.listens_for(db.engine, "close")
|
||||
def close(dbapi_connection, connection_record): # noqa
|
||||
pass
|
||||
|
||||
@event.listens_for(db.engine, "checkout")
|
||||
def checkout(dbapi_connection, connection_record, connection_proxy): # noqa
|
||||
@event.listens_for(db.engine, 'checkout')
|
||||
def checkout(dbapi_connection, connection_record, connection_proxy):
|
||||
try:
|
||||
# connection given to a web worker
|
||||
TOTAL_CHECKED_OUT_DB_CONNECTIONS.inc()
|
||||
|
||||
# this will overwrite any previous checkout_at timestamp
|
||||
connection_record.info["checkout_at"] = time.monotonic()
|
||||
connection_record.info['checkout_at'] = time.monotonic()
|
||||
|
||||
# checkin runs after the request is already torn down, therefore we add the request_data onto the
|
||||
# connection_record as otherwise it won't have that information when checkin actually runs.
|
||||
@@ -356,88 +370,42 @@ def setup_sqlalchemy_events(app):
|
||||
|
||||
# web requests
|
||||
if has_request_context():
|
||||
connection_record.info["request_data"] = {
|
||||
"method": request.method,
|
||||
"host": request.host,
|
||||
"url_rule": (
|
||||
request.url_rule.rule if request.url_rule else "No endpoint"
|
||||
),
|
||||
connection_record.info['request_data'] = {
|
||||
'method': request.method,
|
||||
'host': request.host,
|
||||
'url_rule': request.url_rule.rule if request.url_rule else 'No endpoint'
|
||||
}
|
||||
# celery apps
|
||||
elif current_task:
|
||||
connection_record.info["request_data"] = {
|
||||
"method": "celery",
|
||||
"host": current_app.config["NOTIFY_APP_NAME"], # worker name
|
||||
"url_rule": current_task.name, # task name
|
||||
connection_record.info['request_data'] = {
|
||||
'method': 'celery',
|
||||
'host': current_app.config['NOTIFY_APP_NAME'], # worker name
|
||||
'url_rule': current_task.name, # task name
|
||||
}
|
||||
# anything else. migrations possibly, or flask cli commands.
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
"Checked out sqlalchemy connection from outside of request/task"
|
||||
)
|
||||
connection_record.info["request_data"] = {
|
||||
"method": "unknown",
|
||||
"host": "unknown",
|
||||
"url_rule": "unknown",
|
||||
current_app.logger.warning('Checked out sqlalchemy connection from outside of request/task')
|
||||
connection_record.info['request_data'] = {
|
||||
'method': 'unknown',
|
||||
'host': 'unknown',
|
||||
'url_rule': 'unknown',
|
||||
}
|
||||
except Exception:
|
||||
current_app.logger.exception("Exception caught for checkout event.")
|
||||
|
||||
@event.listens_for(db.engine, "checkin")
|
||||
def checkin(dbapi_connection, connection_record): # noqa
|
||||
pass
|
||||
@event.listens_for(db.engine, 'checkin')
|
||||
def checkin(dbapi_connection, connection_record):
|
||||
try:
|
||||
# connection returned by a web worker
|
||||
TOTAL_CHECKED_OUT_DB_CONNECTIONS.dec()
|
||||
|
||||
# duration that connection was held by a single web request
|
||||
duration = time.monotonic() - connection_record.info['checkout_at']
|
||||
|
||||
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
|
||||
DB_CONNECTION_OPEN_DURATION_SECONDS.labels(
|
||||
connection_record.info['request_data']['method'],
|
||||
connection_record.info['request_data']['host'],
|
||||
connection_record.info['request_data']['url_rule']
|
||||
).observe(duration)
|
||||
except Exception:
|
||||
current_app.logger.exception("Exception caught for checkin event.")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from flask import current_app, g, request
|
||||
from gds_metrics import Histogram
|
||||
from notifications_python_client.authentication import (
|
||||
decode_jwt_token,
|
||||
get_token_issuer,
|
||||
@@ -19,12 +20,15 @@ from app.serialised_models import SerialisedService
|
||||
|
||||
# stvnrlly - this is silly, but bandit has a multiline string bug (https://github.com/PyCQA/bandit/issues/658)
|
||||
# and flake8 wants a multiline quote here. TODO: check on bug status and restore sanity once possible
|
||||
TOKEN_MESSAGE_ONE = (
|
||||
"Invalid token: make sure your API token matches the example " # nosec B105
|
||||
)
|
||||
TOKEN_MESSAGE_ONE = "Invalid token: make sure your API token matches the example " # nosec B105
|
||||
TOKEN_MESSAGE_TWO = "at https://docs.notifications.service.gov.uk/rest-api.html#authorisation-header" # nosec B105
|
||||
GENERAL_TOKEN_ERROR_MESSAGE = TOKEN_MESSAGE_ONE + TOKEN_MESSAGE_TWO
|
||||
|
||||
AUTH_DB_CONNECTION_DURATION_SECONDS = Histogram(
|
||||
'auth_db_connection_duration_seconds',
|
||||
'Time taken to get DB connection and fetch service from database',
|
||||
)
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, message, code, service_id=None, api_key_id=None):
|
||||
@@ -35,18 +39,21 @@ class AuthError(Exception):
|
||||
self.api_key_id = api_key_id
|
||||
|
||||
def __str__(self):
|
||||
return "AuthError({message}, {code}, service_id={service_id}, api_key_id={api_key_id})".format(
|
||||
**self.__dict__
|
||||
)
|
||||
return 'AuthError({message}, {code}, service_id={service_id}, api_key_id={api_key_id})'.format(**self.__dict__)
|
||||
|
||||
def to_dict_v2(self):
|
||||
return {
|
||||
"status_code": self.code,
|
||||
"errors": [{"error": "AuthError", "message": self.short_message}],
|
||||
'status_code': self.code,
|
||||
"errors": [
|
||||
{
|
||||
"error": "AuthError",
|
||||
"message": self.short_message
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class InternalApiKey:
|
||||
class InternalApiKey():
|
||||
def __init__(self, client_id, secret):
|
||||
self.secret = secret
|
||||
self.id = client_id
|
||||
@@ -58,11 +65,11 @@ def requires_no_auth():
|
||||
|
||||
|
||||
def requires_admin_auth():
|
||||
requires_internal_auth(current_app.config.get("ADMIN_CLIENT_ID"))
|
||||
requires_internal_auth(current_app.config.get('ADMIN_CLIENT_ID'))
|
||||
|
||||
|
||||
def requires_internal_auth(expected_client_id):
|
||||
if expected_client_id not in current_app.config.get("INTERNAL_CLIENT_API_KEYS"):
|
||||
if expected_client_id not in current_app.config.get('INTERNAL_CLIENT_API_KEYS'):
|
||||
raise TypeError("Unknown client_id for internal auth")
|
||||
|
||||
request_helper.check_proxy_header_before_request()
|
||||
@@ -70,13 +77,13 @@ def requires_internal_auth(expected_client_id):
|
||||
client_id = _get_token_issuer(auth_token)
|
||||
|
||||
if client_id != expected_client_id:
|
||||
current_app.logger.info("client_id: %s", client_id)
|
||||
current_app.logger.info("expected_client_id: %s", expected_client_id)
|
||||
current_app.logger.info('client_id: %s', client_id)
|
||||
current_app.logger.info('expected_client_id: %s', expected_client_id)
|
||||
raise AuthError("Unauthorized: not allowed to perform this action", 401)
|
||||
|
||||
api_keys = [
|
||||
InternalApiKey(client_id, secret)
|
||||
for secret in current_app.config.get("INTERNAL_CLIENT_API_KEYS")[client_id]
|
||||
for secret in current_app.config.get('INTERNAL_CLIENT_API_KEYS')[client_id]
|
||||
]
|
||||
|
||||
_decode_jwt_token(auth_token, api_keys, client_id)
|
||||
@@ -87,9 +94,7 @@ def requires_auth():
|
||||
request_helper.check_proxy_header_before_request()
|
||||
|
||||
auth_token = _get_auth_token(request)
|
||||
issuer = _get_token_issuer(
|
||||
auth_token
|
||||
) # ie the `iss` claim which should be a service ID
|
||||
issuer = _get_token_issuer(auth_token) # ie the `iss` claim which should be a service ID
|
||||
|
||||
try:
|
||||
service_id = uuid.UUID(issuer)
|
||||
@@ -97,27 +102,25 @@ def requires_auth():
|
||||
raise AuthError("Invalid token: service id is not the right data type", 403)
|
||||
|
||||
try:
|
||||
service = SerialisedService.from_id(service_id)
|
||||
with AUTH_DB_CONNECTION_DURATION_SECONDS.time():
|
||||
service = SerialisedService.from_id(service_id)
|
||||
except NoResultFound:
|
||||
raise AuthError("Invalid token: service not found", 403)
|
||||
|
||||
if not service.api_keys:
|
||||
raise AuthError(
|
||||
"Invalid token: service has no API keys", 403, service_id=service.id
|
||||
)
|
||||
raise AuthError("Invalid token: service has no API keys", 403, service_id=service.id)
|
||||
|
||||
if not service.active:
|
||||
raise AuthError(
|
||||
"Invalid token: service is archived", 403, service_id=service.id
|
||||
)
|
||||
raise AuthError("Invalid token: service is archived", 403, service_id=service.id)
|
||||
|
||||
api_key = _decode_jwt_token(auth_token, service.api_keys, service.id)
|
||||
|
||||
current_app.logger.info(
|
||||
"API authorised for service {} with api key {}, using issuer {} for URL: {}".format(
|
||||
service_id, api_key.id, request.headers.get("User-Agent"), request.base_url
|
||||
)
|
||||
)
|
||||
current_app.logger.info('API authorised for service {} with api key {}, using issuer {} for URL: {}'.format(
|
||||
service_id,
|
||||
api_key.id,
|
||||
request.headers.get('User-Agent'),
|
||||
request.base_url
|
||||
))
|
||||
|
||||
g.api_user = api_key
|
||||
g.service_id = service_id
|
||||
@@ -129,13 +132,9 @@ def _decode_jwt_token(auth_token, api_keys, service_id=None):
|
||||
try:
|
||||
decode_jwt_token(auth_token, api_key.secret)
|
||||
except TokenExpiredError:
|
||||
if not current_app.config.get("ALLOW_EXPIRED_API_TOKEN", False):
|
||||
err_msg = (
|
||||
"Error: Your system clock must be accurate to within 30 seconds"
|
||||
)
|
||||
raise AuthError(
|
||||
err_msg, 403, service_id=service_id, api_key_id=api_key.id
|
||||
)
|
||||
if not current_app.config.get('ALLOW_EXPIRED_API_TOKEN', False):
|
||||
err_msg = "Error: Your system clock must be accurate to within 30 seconds"
|
||||
raise AuthError(err_msg, 403, service_id=service_id, api_key_id=api_key.id)
|
||||
except TokenAlgorithmError:
|
||||
err_msg = "Invalid token: algorithm used is not HS256"
|
||||
raise AuthError(err_msg, 403, service_id=service_id, api_key_id=api_key.id)
|
||||
@@ -148,20 +147,10 @@ def _decode_jwt_token(auth_token, api_keys, service_id=None):
|
||||
continue
|
||||
except TokenError:
|
||||
# General error when trying to decode and validate the token
|
||||
raise AuthError(
|
||||
GENERAL_TOKEN_ERROR_MESSAGE,
|
||||
403,
|
||||
service_id=service_id,
|
||||
api_key_id=api_key.id,
|
||||
)
|
||||
raise AuthError(GENERAL_TOKEN_ERROR_MESSAGE, 403, service_id=service_id, api_key_id=api_key.id)
|
||||
|
||||
if api_key.expiry_date:
|
||||
raise AuthError(
|
||||
"Invalid token: API key revoked",
|
||||
403,
|
||||
service_id=service_id,
|
||||
api_key_id=api_key.id,
|
||||
)
|
||||
raise AuthError("Invalid token: API key revoked", 403, service_id=service_id, api_key_id=api_key.id)
|
||||
|
||||
return api_key
|
||||
else:
|
||||
@@ -170,14 +159,14 @@ def _decode_jwt_token(auth_token, api_keys, service_id=None):
|
||||
|
||||
|
||||
def _get_auth_token(req):
|
||||
auth_header = req.headers.get("Authorization", None)
|
||||
auth_header = req.headers.get('Authorization', None)
|
||||
if not auth_header:
|
||||
raise AuthError("Unauthorized: authentication token must be provided", 401)
|
||||
raise AuthError('Unauthorized: authentication token must be provided', 401)
|
||||
|
||||
auth_scheme = auth_header[:7].title()
|
||||
|
||||
if auth_scheme != "Bearer ":
|
||||
raise AuthError("Unauthorized: authentication bearer scheme must be used", 401)
|
||||
if auth_scheme != 'Bearer ':
|
||||
raise AuthError('Unauthorized: authentication bearer scheme must be used', 401)
|
||||
|
||||
return auth_header[7:]
|
||||
|
||||
|
||||
275
app/aws/s3.py
275
app/aws/s3.py
@@ -1,261 +1,112 @@
|
||||
import re
|
||||
import os
|
||||
|
||||
import botocore
|
||||
from boto3 import Session
|
||||
from expiringdict import ExpiringDict
|
||||
from boto3 import Session, client
|
||||
from flask import current_app
|
||||
|
||||
from app import redis_store
|
||||
from app.clients import AWS_CLIENT_CONFIG
|
||||
FILE_LOCATION_STRUCTURE = 'service-{}-notify/{}.csv'
|
||||
|
||||
FILE_LOCATION_STRUCTURE = "service-{}-notify/{}.csv"
|
||||
default_access_key = os.environ.get('AWS_ACCESS_KEY_ID')
|
||||
default_secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY')
|
||||
default_region = os.environ.get('AWS_REGION')
|
||||
|
||||
|
||||
JOBS = ExpiringDict(max_len=1000, max_age_seconds=3600 * 4)
|
||||
|
||||
|
||||
JOBS_CACHE_HITS = "JOBS_CACHE_HITS"
|
||||
JOBS_CACHE_MISSES = "JOBS_CACHE_MISSES"
|
||||
|
||||
|
||||
def get_s3_file(bucket_name, file_location, access_key, secret_key, region):
|
||||
def get_s3_file(
|
||||
bucket_name, file_location, access_key=default_access_key, secret_key=default_secret_key, region=default_region
|
||||
):
|
||||
s3_file = get_s3_object(bucket_name, file_location, access_key, secret_key, region)
|
||||
return s3_file.get()["Body"].read().decode("utf-8")
|
||||
return s3_file.get()['Body'].read().decode('utf-8')
|
||||
|
||||
|
||||
def get_s3_object(bucket_name, file_location, access_key, secret_key, region):
|
||||
session = Session(
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
)
|
||||
s3 = session.resource("s3", config=AWS_CLIENT_CONFIG)
|
||||
def get_s3_object(
|
||||
bucket_name, file_location, access_key=default_access_key, secret_key=default_secret_key, region=default_region
|
||||
):
|
||||
session = Session(aws_access_key_id=access_key, aws_secret_access_key=secret_key, region_name=region)
|
||||
s3 = session.resource('s3')
|
||||
return s3.Object(bucket_name, file_location)
|
||||
|
||||
|
||||
def purge_bucket(bucket_name, access_key, secret_key, region):
|
||||
session = Session(
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
)
|
||||
s3 = session.resource("s3", config=AWS_CLIENT_CONFIG)
|
||||
bucket = s3.Bucket(bucket_name)
|
||||
bucket.objects.all().delete()
|
||||
|
||||
|
||||
def file_exists(bucket_name, file_location, access_key, secret_key, region):
|
||||
def file_exists(
|
||||
bucket_name, file_location, access_key=default_access_key, secret_key=default_secret_key, region=default_region
|
||||
):
|
||||
try:
|
||||
# try and access metadata of object
|
||||
get_s3_object(
|
||||
bucket_name, file_location, access_key, secret_key, region
|
||||
).metadata
|
||||
get_s3_object(bucket_name, file_location, access_key, secret_key, region).metadata
|
||||
return True
|
||||
except botocore.exceptions.ClientError as e:
|
||||
if e.response["ResponseMetadata"]["HTTPStatusCode"] == 404:
|
||||
if e.response['ResponseMetadata']['HTTPStatusCode'] == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def get_job_location(service_id, job_id):
|
||||
return (
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
|
||||
current_app.config['CSV_UPLOAD_BUCKET']['bucket'],
|
||||
FILE_LOCATION_STRUCTURE.format(service_id, job_id),
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"],
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"],
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["region"],
|
||||
current_app.config['CSV_UPLOAD_BUCKET']['access_key_id'],
|
||||
current_app.config['CSV_UPLOAD_BUCKET']['secret_access_key'],
|
||||
current_app.config['CSV_UPLOAD_BUCKET']['region'],
|
||||
)
|
||||
|
||||
|
||||
def get_contact_list_location(service_id, contact_list_id):
|
||||
return (
|
||||
current_app.config['CONTACT_LIST_BUCKET']['bucket'],
|
||||
FILE_LOCATION_STRUCTURE.format(service_id, contact_list_id),
|
||||
current_app.config['CONTACT_LIST_BUCKET']['access_key_id'],
|
||||
current_app.config['CONTACT_LIST_BUCKET']['secret_access_key'],
|
||||
current_app.config['CONTACT_LIST_BUCKET']['region'],
|
||||
)
|
||||
|
||||
|
||||
def get_job_and_metadata_from_s3(service_id, job_id):
|
||||
obj = get_s3_object(*get_job_location(service_id, job_id))
|
||||
return obj.get()["Body"].read().decode("utf-8"), obj.get()["Metadata"]
|
||||
return obj.get()['Body'].read().decode('utf-8'), obj.get()['Metadata']
|
||||
|
||||
|
||||
def get_job_from_s3(service_id, job_id):
|
||||
obj = get_s3_object(*get_job_location(service_id, job_id))
|
||||
return obj.get()["Body"].read().decode("utf-8")
|
||||
|
||||
|
||||
def incr_jobs_cache_misses():
|
||||
if not redis_store.get(JOBS_CACHE_MISSES):
|
||||
redis_store.set(JOBS_CACHE_MISSES, 1)
|
||||
else:
|
||||
redis_store.incr(JOBS_CACHE_MISSES)
|
||||
hits = redis_store.get(JOBS_CACHE_HITS).decode("utf-8")
|
||||
misses = redis_store.get(JOBS_CACHE_MISSES).decode("utf-8")
|
||||
current_app.logger.debug(f"JOBS CACHE MISS hits {hits} misses {misses}")
|
||||
|
||||
|
||||
def incr_jobs_cache_hits():
|
||||
if not redis_store.get(JOBS_CACHE_HITS):
|
||||
redis_store.set(JOBS_CACHE_HITS, 1)
|
||||
else:
|
||||
redis_store.incr(JOBS_CACHE_HITS)
|
||||
hits = redis_store.get(JOBS_CACHE_HITS).decode("utf-8")
|
||||
misses = redis_store.get(JOBS_CACHE_MISSES).decode("utf-8")
|
||||
current_app.logger.debug(f"JOBS CACHE HIT hits {hits} misses {misses}")
|
||||
|
||||
|
||||
def extract_phones(job):
|
||||
job = job.split("\r\n")
|
||||
first_row = job[0]
|
||||
job.pop(0)
|
||||
first_row = first_row.split(",")
|
||||
current_app.logger.info(f"HEADERS {first_row}")
|
||||
phone_index = 0
|
||||
for item in first_row:
|
||||
# Note: may contain a BOM and look like \ufeffphone number
|
||||
if "phone number" in item.lower():
|
||||
break
|
||||
phone_index = phone_index + 1
|
||||
|
||||
phones = {}
|
||||
job_row = 0
|
||||
for row in job:
|
||||
row = row.split(",")
|
||||
|
||||
if phone_index >= len(row):
|
||||
phones[job_row] = "Unavailable"
|
||||
current_app.logger.error(
|
||||
"Corrupt csv file, missing columns or possibly a byte order mark in the file"
|
||||
)
|
||||
|
||||
else:
|
||||
my_phone = row[phone_index]
|
||||
my_phone = re.sub(r"[\+\s\(\)\-\.]*", "", my_phone)
|
||||
phones[job_row] = my_phone
|
||||
job_row = job_row + 1
|
||||
return phones
|
||||
|
||||
|
||||
def extract_personalisation(job):
|
||||
job = job.split("\r\n")
|
||||
first_row = job[0]
|
||||
job.pop(0)
|
||||
first_row = first_row.split(",")
|
||||
personalisation = {}
|
||||
job_row = 0
|
||||
for row in job:
|
||||
row = row.split(",")
|
||||
temp = dict(zip(first_row, row))
|
||||
personalisation[job_row] = temp
|
||||
job_row = job_row + 1
|
||||
return personalisation
|
||||
|
||||
|
||||
def get_phone_number_from_s3(service_id, job_id, job_row_number):
|
||||
# We don't want to constantly pull down a job from s3 every time we need a phone number.
|
||||
# At the same time we don't want to store it in redis or the db
|
||||
# So this is a little recycling mechanism to reduce the number of downloads.
|
||||
job = JOBS.get(job_id)
|
||||
if job is None:
|
||||
job = get_job_from_s3(service_id, job_id)
|
||||
JOBS[job_id] = job
|
||||
incr_jobs_cache_misses()
|
||||
else:
|
||||
incr_jobs_cache_hits()
|
||||
|
||||
# If the job is None after our attempt to retrieve it from s3, it
|
||||
# probably means the job is old and has been deleted from s3, in
|
||||
# which case there is nothing we can do. It's unlikely to run into
|
||||
# this, but it could theoretically happen, especially if we ever
|
||||
# change the task schedules
|
||||
if job is None:
|
||||
current_app.logger.warning(
|
||||
f"Couldnt find phone for job_id {job_id} row number {job_row_number} because job is missing"
|
||||
)
|
||||
return "Unavailable"
|
||||
|
||||
# If we look in the JOBS cache for the quick lookup dictionary of phones for a given job
|
||||
# and that dictionary is not there, create it
|
||||
if JOBS.get(f"{job_id}_phones") is None:
|
||||
JOBS[f"{job_id}_phones"] = extract_phones(job)
|
||||
|
||||
# If we can find the quick dictionary, use it
|
||||
if JOBS.get(f"{job_id}_phones") is not None:
|
||||
phone_to_return = JOBS.get(f"{job_id}_phones").get(job_row_number)
|
||||
if phone_to_return:
|
||||
return phone_to_return
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
f"Was unable to retrieve phone number from lookup dictionary for job {job_id}"
|
||||
)
|
||||
return "Unavailable"
|
||||
else:
|
||||
current_app.logger.error(
|
||||
f"Was unable to construct lookup dictionary for job {job_id}"
|
||||
)
|
||||
return "Unavailable"
|
||||
|
||||
|
||||
def get_personalisation_from_s3(service_id, job_id, job_row_number):
|
||||
# We don't want to constantly pull down a job from s3 every time we need the personalisation.
|
||||
# At the same time we don't want to store it in redis or the db
|
||||
# So this is a little recycling mechanism to reduce the number of downloads.
|
||||
job = JOBS.get(job_id)
|
||||
if job is None:
|
||||
job = get_job_from_s3(service_id, job_id)
|
||||
JOBS[job_id] = job
|
||||
incr_jobs_cache_misses()
|
||||
else:
|
||||
incr_jobs_cache_hits()
|
||||
|
||||
# If the job is None after our attempt to retrieve it from s3, it
|
||||
# probably means the job is old and has been deleted from s3, in
|
||||
# which case there is nothing we can do. It's unlikely to run into
|
||||
# this, but it could theoretically happen, especially if we ever
|
||||
# change the task schedules
|
||||
if job is None:
|
||||
current_app.logger.warning(
|
||||
"Couldnt find personalisation for job_id {job_id} row number {job_row_number} because job is missing"
|
||||
)
|
||||
return {}
|
||||
|
||||
# If we look in the JOBS cache for the quick lookup dictionary of personalisations for a given job
|
||||
# and that dictionary is not there, create it
|
||||
if JOBS.get(f"{job_id}_personalisation") is None:
|
||||
JOBS[f"{job_id}_personalisation"] = extract_personalisation(job)
|
||||
|
||||
# If we can find the quick dictionary, use it
|
||||
if JOBS.get(f"{job_id}_personalisation") is not None:
|
||||
personalisation_to_return = JOBS.get(f"{job_id}_personalisation").get(
|
||||
job_row_number
|
||||
)
|
||||
if personalisation_to_return:
|
||||
return personalisation_to_return
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
f"Was unable to retrieve personalisation from lookup dictionary for job {job_id}"
|
||||
)
|
||||
return {}
|
||||
else:
|
||||
current_app.logger.error(
|
||||
f"Was unable to construct lookup dictionary for job {job_id}"
|
||||
)
|
||||
return {}
|
||||
return obj.get()['Body'].read().decode('utf-8')
|
||||
|
||||
|
||||
def get_job_metadata_from_s3(service_id, job_id):
|
||||
obj = get_s3_object(*get_job_location(service_id, job_id))
|
||||
return obj.get()["Metadata"]
|
||||
return obj.get()['Metadata']
|
||||
|
||||
|
||||
def remove_job_from_s3(service_id, job_id):
|
||||
return remove_s3_object(*get_job_location(service_id, job_id))
|
||||
|
||||
|
||||
def remove_contact_list_from_s3(service_id, contact_list_id):
|
||||
return remove_s3_object(*get_contact_list_location(service_id, contact_list_id))
|
||||
|
||||
|
||||
def remove_s3_object(bucket_name, object_key, access_key, secret_key, region):
|
||||
obj = get_s3_object(bucket_name, object_key, access_key, secret_key, region)
|
||||
return obj.delete()
|
||||
|
||||
|
||||
def remove_csv_object(object_key):
|
||||
obj = get_s3_object(
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
|
||||
object_key,
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"],
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"],
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["region"],
|
||||
def get_list_of_files_by_suffix(
|
||||
bucket_name,
|
||||
subfolder='',
|
||||
suffix='',
|
||||
last_modified=None,
|
||||
access_key=default_access_key,
|
||||
secret_key=default_secret_key,
|
||||
region=default_region
|
||||
):
|
||||
s3_client = client('s3', region, aws_access_key_id=access_key, aws_secret_access_key=secret_key)
|
||||
paginator = s3_client.get_paginator('list_objects_v2')
|
||||
|
||||
page_iterator = paginator.paginate(
|
||||
Bucket=bucket_name,
|
||||
Prefix=subfolder
|
||||
)
|
||||
return obj.delete()
|
||||
|
||||
for page in page_iterator:
|
||||
for obj in page.get('Contents', []):
|
||||
key = obj['Key']
|
||||
if key.lower().endswith(suffix.lower()):
|
||||
if not last_modified or obj['LastModified'] >= last_modified:
|
||||
yield key
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.enums import NotificationType
|
||||
|
||||
create_or_update_free_sms_fragment_limit_schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "POST annual billing schema",
|
||||
@@ -10,7 +8,7 @@ create_or_update_free_sms_fragment_limit_schema = {
|
||||
"properties": {
|
||||
"free_sms_fragment_limit": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["free_sms_fragment_limit"],
|
||||
"required": ["free_sms_fragment_limit"]
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +25,7 @@ def serialize_ft_billing_remove_emails(rows):
|
||||
"charged_units": row.charged_units,
|
||||
}
|
||||
for row in rows
|
||||
if row.notification_type != NotificationType.EMAIL
|
||||
if row.notification_type != 'email'
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.dao.annual_billing_dao import (
|
||||
dao_update_annual_billing_for_future_years,
|
||||
set_default_free_allowance_for_service,
|
||||
)
|
||||
from app.dao.date_util import get_current_calendar_year_start_year
|
||||
from app.dao.date_util import get_current_financial_year_start_year
|
||||
from app.dao.fact_billing_dao import (
|
||||
fetch_billing_totals_for_year,
|
||||
fetch_monthly_billing_for_year,
|
||||
@@ -21,84 +21,86 @@ from app.models import Service
|
||||
from app.schema_validation import validate
|
||||
|
||||
billing_blueprint = Blueprint(
|
||||
"billing", __name__, url_prefix="/service/<uuid:service_id>/billing"
|
||||
'billing',
|
||||
__name__,
|
||||
url_prefix='/service/<uuid:service_id>/billing'
|
||||
)
|
||||
|
||||
|
||||
register_errors(billing_blueprint)
|
||||
|
||||
|
||||
@billing_blueprint.route("/monthly-usage")
|
||||
@billing_blueprint.route('/monthly-usage')
|
||||
def get_yearly_usage_by_monthly_from_ft_billing(service_id):
|
||||
try:
|
||||
year = int(request.args.get("year"))
|
||||
year = int(request.args.get('year'))
|
||||
except TypeError:
|
||||
return jsonify(result="error", message="No valid year provided"), 400
|
||||
return jsonify(result='error', message='No valid year provided'), 400
|
||||
results = fetch_monthly_billing_for_year(service_id=service_id, year=year)
|
||||
data = serialize_ft_billing_remove_emails(results)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@billing_blueprint.route("/yearly-usage-summary")
|
||||
@billing_blueprint.route('/yearly-usage-summary')
|
||||
def get_yearly_billing_usage_summary_from_ft_billing(service_id):
|
||||
try:
|
||||
year = int(request.args.get("year"))
|
||||
year = int(request.args.get('year'))
|
||||
except TypeError:
|
||||
return jsonify(result="error", message="No valid year provided"), 400
|
||||
return jsonify(result='error', message='No valid year provided'), 400
|
||||
|
||||
billing_data = fetch_billing_totals_for_year(service_id, year)
|
||||
data = serialize_ft_billing_yearly_totals(billing_data)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@billing_blueprint.route("/free-sms-fragment-limit", methods=["GET"])
|
||||
@billing_blueprint.route('/free-sms-fragment-limit', methods=["GET"])
|
||||
def get_free_sms_fragment_limit(service_id):
|
||||
financial_year_start = request.args.get("financial_year_start")
|
||||
|
||||
annual_billing = dao_get_free_sms_fragment_limit_for_year(
|
||||
service_id, financial_year_start
|
||||
)
|
||||
financial_year_start = request.args.get('financial_year_start')
|
||||
|
||||
annual_billing = dao_get_free_sms_fragment_limit_for_year(service_id, financial_year_start)
|
||||
|
||||
if annual_billing is None:
|
||||
service = Service.query.get(service_id)
|
||||
# An entry does not exist in annual_billing table for that service and year.
|
||||
# Set the annual billing to the default free allowance based on the organization type of the service.
|
||||
# Set the annual billing to the default free allowance based on the organisation type of the service.
|
||||
|
||||
annual_billing = set_default_free_allowance_for_service(
|
||||
service=service,
|
||||
year_start=int(financial_year_start) if financial_year_start else None,
|
||||
year_start=int(financial_year_start) if financial_year_start else None
|
||||
)
|
||||
|
||||
return jsonify(annual_billing.serialize_free_sms_items()), 200
|
||||
|
||||
|
||||
@billing_blueprint.route("/free-sms-fragment-limit", methods=["POST"])
|
||||
@billing_blueprint.route('/free-sms-fragment-limit', methods=["POST"])
|
||||
def create_or_update_free_sms_fragment_limit(service_id):
|
||||
|
||||
req_args = request.get_json()
|
||||
|
||||
form = validate(req_args, create_or_update_free_sms_fragment_limit_schema)
|
||||
|
||||
update_free_sms_fragment_limit_data(
|
||||
service_id,
|
||||
free_sms_fragment_limit=form.get("free_sms_fragment_limit"),
|
||||
financial_year_start=form.get("financial_year_start"),
|
||||
)
|
||||
update_free_sms_fragment_limit_data(service_id,
|
||||
free_sms_fragment_limit=form.get('free_sms_fragment_limit'),
|
||||
financial_year_start=form.get('financial_year_start'))
|
||||
return jsonify(form), 201
|
||||
|
||||
|
||||
def update_free_sms_fragment_limit_data(
|
||||
service_id, free_sms_fragment_limit, financial_year_start
|
||||
):
|
||||
current_year = get_current_calendar_year_start_year()
|
||||
def update_free_sms_fragment_limit_data(service_id, free_sms_fragment_limit, financial_year_start):
|
||||
current_year = get_current_financial_year_start_year()
|
||||
if not financial_year_start:
|
||||
financial_year_start = current_year
|
||||
|
||||
dao_create_or_update_annual_billing_for_year(
|
||||
service_id, free_sms_fragment_limit, financial_year_start
|
||||
service_id,
|
||||
free_sms_fragment_limit,
|
||||
financial_year_start
|
||||
)
|
||||
# if we're trying to update historical data, don't touch other rows.
|
||||
# Otherwise, make sure that future years will get the new updated value.
|
||||
if financial_year_start >= current_year:
|
||||
dao_update_annual_billing_for_future_years(
|
||||
service_id, free_sms_fragment_limit, financial_year_start
|
||||
service_id,
|
||||
free_sms_fragment_limit,
|
||||
financial_year_start
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.timezones import convert_utc_to_local_timezone
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app import notify_celery
|
||||
from app import notify_celery, statsd_client
|
||||
from app.aws import s3
|
||||
from app.aws.s3 import remove_csv_object
|
||||
from app.celery.process_ses_receipts_tasks import check_and_queue_callback_task
|
||||
from app.config import QueueNames
|
||||
from app.cronitor import cronitor
|
||||
@@ -14,7 +14,6 @@ from app.dao.inbound_sms_dao import delete_inbound_sms_older_than_retention
|
||||
from app.dao.jobs_dao import (
|
||||
dao_archive_job,
|
||||
dao_get_jobs_older_than_data_retention,
|
||||
dao_get_unfinished_jobs,
|
||||
)
|
||||
from app.dao.notifications_dao import (
|
||||
dao_get_notifications_processing_time_stats,
|
||||
@@ -25,15 +24,14 @@ from app.dao.notifications_dao import (
|
||||
from app.dao.service_data_retention_dao import (
|
||||
fetch_service_data_retention_for_all_services_by_notification_type,
|
||||
)
|
||||
from app.enums import NotificationType
|
||||
from app.models import FactProcessingTime
|
||||
from app.utils import get_midnight_in_utc
|
||||
from app.models import EMAIL_TYPE, SMS_TYPE, FactProcessingTime
|
||||
from app.utils import get_local_midnight_in_utc
|
||||
|
||||
|
||||
@notify_celery.task(name="remove_sms_email_jobs")
|
||||
@cronitor("remove_sms_email_jobs")
|
||||
def remove_sms_email_csv_files():
|
||||
_remove_csv_files([NotificationType.EMAIL, NotificationType.SMS])
|
||||
_remove_csv_files([EMAIL_TYPE, SMS_TYPE])
|
||||
|
||||
|
||||
def _remove_csv_files(job_types):
|
||||
@@ -44,98 +42,68 @@ def _remove_csv_files(job_types):
|
||||
current_app.logger.info("Job ID {} has been removed from s3.".format(job.id))
|
||||
|
||||
|
||||
@notify_celery.task(name="cleanup-unfinished-jobs")
|
||||
def cleanup_unfinished_jobs():
|
||||
now = datetime.utcnow()
|
||||
jobs = dao_get_unfinished_jobs()
|
||||
for job in jobs:
|
||||
# The query already checks that the processing_finished time is null, so here we are saying
|
||||
# if it started more than 4 hours ago, that's too long
|
||||
acceptable_finish_time = job.processing_started + timedelta(minutes=5)
|
||||
if now > acceptable_finish_time:
|
||||
remove_csv_object(job.original_file_name)
|
||||
dao_archive_job(job)
|
||||
|
||||
|
||||
@notify_celery.task(name="delete-notifications-older-than-retention")
|
||||
def delete_notifications_older_than_retention():
|
||||
delete_email_notifications_older_than_retention.apply_async(
|
||||
queue=QueueNames.REPORTING
|
||||
)
|
||||
delete_sms_notifications_older_than_retention.apply_async(
|
||||
queue=QueueNames.REPORTING
|
||||
)
|
||||
delete_email_notifications_older_than_retention.apply_async(queue=QueueNames.REPORTING)
|
||||
delete_sms_notifications_older_than_retention.apply_async(queue=QueueNames.REPORTING)
|
||||
|
||||
|
||||
@notify_celery.task(name="delete-sms-notifications")
|
||||
@cronitor("delete-sms-notifications")
|
||||
def delete_sms_notifications_older_than_retention():
|
||||
_delete_notifications_older_than_retention_by_type(NotificationType.SMS)
|
||||
_delete_notifications_older_than_retention_by_type('sms')
|
||||
|
||||
|
||||
@notify_celery.task(name="delete-email-notifications")
|
||||
@cronitor("delete-email-notifications")
|
||||
def delete_email_notifications_older_than_retention():
|
||||
_delete_notifications_older_than_retention_by_type(NotificationType.EMAIL)
|
||||
_delete_notifications_older_than_retention_by_type('email')
|
||||
|
||||
|
||||
def _delete_notifications_older_than_retention_by_type(notification_type):
|
||||
flexible_data_retention = (
|
||||
fetch_service_data_retention_for_all_services_by_notification_type(
|
||||
notification_type
|
||||
)
|
||||
)
|
||||
flexible_data_retention = fetch_service_data_retention_for_all_services_by_notification_type(notification_type)
|
||||
|
||||
for f in flexible_data_retention:
|
||||
day_to_delete_backwards_from = get_midnight_in_utc(
|
||||
datetime.utcnow()
|
||||
).date() - timedelta(days=f.days_of_retention)
|
||||
|
||||
delete_notifications_for_service_and_type.apply_async(
|
||||
queue=QueueNames.REPORTING,
|
||||
kwargs={
|
||||
"service_id": f.service_id,
|
||||
"notification_type": notification_type,
|
||||
"datetime_to_delete_before": day_to_delete_backwards_from,
|
||||
},
|
||||
day_to_delete_backwards_from = get_local_midnight_in_utc(
|
||||
convert_utc_to_local_timezone(datetime.utcnow()).date() - timedelta(days=f.days_of_retention)
|
||||
)
|
||||
|
||||
seven_days_ago = get_midnight_in_utc(datetime.utcnow()).date() - timedelta(days=7)
|
||||
delete_notifications_for_service_and_type.apply_async(queue=QueueNames.REPORTING, kwargs={
|
||||
'service_id': f.service_id,
|
||||
'notification_type': notification_type,
|
||||
'datetime_to_delete_before': day_to_delete_backwards_from
|
||||
})
|
||||
|
||||
seven_days_ago = get_local_midnight_in_utc(
|
||||
convert_utc_to_local_timezone(datetime.utcnow()).date() - timedelta(days=7)
|
||||
)
|
||||
service_ids_with_data_retention = {x.service_id for x in flexible_data_retention}
|
||||
|
||||
# get a list of all service ids that we'll need to delete for. Typically that might only be 5% of services.
|
||||
# This query takes a couple of mins to run.
|
||||
service_ids_that_have_sent_notifications_recently = (
|
||||
get_service_ids_with_notifications_before(notification_type, seven_days_ago)
|
||||
service_ids_that_have_sent_notifications_recently = get_service_ids_with_notifications_before(
|
||||
notification_type,
|
||||
seven_days_ago
|
||||
)
|
||||
|
||||
service_ids_to_purge = (
|
||||
service_ids_that_have_sent_notifications_recently
|
||||
- service_ids_with_data_retention
|
||||
)
|
||||
service_ids_to_purge = service_ids_that_have_sent_notifications_recently - service_ids_with_data_retention
|
||||
|
||||
for service_id in service_ids_to_purge:
|
||||
delete_notifications_for_service_and_type.apply_async(
|
||||
queue=QueueNames.REPORTING,
|
||||
kwargs={
|
||||
"service_id": service_id,
|
||||
"notification_type": notification_type,
|
||||
"datetime_to_delete_before": seven_days_ago,
|
||||
},
|
||||
)
|
||||
delete_notifications_for_service_and_type.apply_async(queue=QueueNames.REPORTING, kwargs={
|
||||
'service_id': service_id,
|
||||
'notification_type': notification_type,
|
||||
'datetime_to_delete_before': seven_days_ago
|
||||
})
|
||||
|
||||
current_app.logger.info(
|
||||
f"delete-notifications-older-than-retention: triggered subtasks for notification_type {notification_type}: "
|
||||
f"{len(service_ids_with_data_retention)} services with flexible data retention, "
|
||||
f"{len(service_ids_to_purge)} services without flexible data retention"
|
||||
f'delete-notifications-older-than-retention: triggered subtasks for notification_type {notification_type}: '
|
||||
f'{len(service_ids_with_data_retention)} services with flexible data retention, '
|
||||
f'{len(service_ids_to_purge)} services without flexible data retention'
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name="delete-notifications-for-service-and-type")
|
||||
def delete_notifications_for_service_and_type(
|
||||
service_id, notification_type, datetime_to_delete_before
|
||||
):
|
||||
@notify_celery.task(name='delete-notifications-for-service-and-type')
|
||||
def delete_notifications_for_service_and_type(service_id, notification_type, datetime_to_delete_before):
|
||||
start = datetime.utcnow()
|
||||
num_deleted = move_notifications_to_notification_history(
|
||||
notification_type,
|
||||
@@ -145,34 +113,32 @@ def delete_notifications_for_service_and_type(
|
||||
if num_deleted:
|
||||
end = datetime.utcnow()
|
||||
current_app.logger.info(
|
||||
f"delete-notifications-for-service-and-type: "
|
||||
f"service: {service_id}, "
|
||||
f"notification_type: {notification_type}, "
|
||||
f"count deleted: {num_deleted}, "
|
||||
f"duration: {(end - start).seconds} seconds"
|
||||
f'delete-notifications-for-service-and-type: '
|
||||
f'service: {service_id}, '
|
||||
f'notification_type: {notification_type}, '
|
||||
f'count deleted: {num_deleted}, '
|
||||
f'duration: {(end - start).seconds} seconds'
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name="timeout-sending-notifications")
|
||||
@cronitor("timeout-sending-notifications")
|
||||
@notify_celery.task(name='timeout-sending-notifications')
|
||||
@cronitor('timeout-sending-notifications')
|
||||
def timeout_notifications():
|
||||
notifications = ["dummy value so len() > 0"]
|
||||
notifications = ['dummy value so len() > 0']
|
||||
|
||||
cutoff_time = datetime.utcnow() - timedelta(
|
||||
seconds=current_app.config.get("SENDING_NOTIFICATIONS_TIMEOUT_PERIOD")
|
||||
seconds=current_app.config.get('SENDING_NOTIFICATIONS_TIMEOUT_PERIOD')
|
||||
)
|
||||
|
||||
while len(notifications) > 0:
|
||||
notifications = dao_timeout_notifications(cutoff_time)
|
||||
|
||||
for notification in notifications:
|
||||
statsd_client.incr(f'timeout-sending.{notification.sent_by}')
|
||||
check_and_queue_callback_task(notification)
|
||||
|
||||
current_app.logger.info(
|
||||
"Timeout period reached for {} notifications, status has been updated.".format(
|
||||
len(notifications)
|
||||
)
|
||||
)
|
||||
"Timeout period reached for {} notifications, status has been updated.".format(len(notifications)))
|
||||
|
||||
|
||||
@notify_celery.task(name="delete-inbound-sms")
|
||||
@@ -183,7 +149,9 @@ def delete_inbound_sms():
|
||||
deleted = delete_inbound_sms_older_than_retention()
|
||||
current_app.logger.info(
|
||||
"Delete inbound sms job started {} finished {} deleted {} inbound sms notifications".format(
|
||||
start, datetime.utcnow(), deleted
|
||||
start,
|
||||
datetime.utcnow(),
|
||||
deleted
|
||||
)
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
@@ -191,7 +159,7 @@ def delete_inbound_sms():
|
||||
raise
|
||||
|
||||
|
||||
@notify_celery.task(name="save-daily-notification-processing-time")
|
||||
@notify_celery.task(name='save-daily-notification-processing-time')
|
||||
@cronitor("save-daily-notification-processing-time")
|
||||
def save_daily_notification_processing_time(local_date=None):
|
||||
# local_date is a string in the format of "YYYY-MM-DD"
|
||||
@@ -201,13 +169,13 @@ def save_daily_notification_processing_time(local_date=None):
|
||||
else:
|
||||
local_date = datetime.strptime(local_date, "%Y-%m-%d").date()
|
||||
|
||||
start_time = get_midnight_in_utc(local_date)
|
||||
end_time = get_midnight_in_utc(local_date + timedelta(days=1))
|
||||
start_time = get_local_midnight_in_utc(local_date)
|
||||
end_time = get_local_midnight_in_utc(local_date + timedelta(days=1))
|
||||
result = dao_get_notifications_processing_time_stats(start_time, end_time)
|
||||
insert_update_processing_time(
|
||||
FactProcessingTime(
|
||||
local_date=local_date,
|
||||
messages_total=result.messages_total,
|
||||
messages_within_10_secs=result.messages_within_10_secs,
|
||||
messages_within_10_secs=result.messages_within_10_secs
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from celery.exceptions import Retry
|
||||
from flask import current_app, json
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from app import notify_celery
|
||||
from app import notify_celery, statsd_client
|
||||
from app.celery.service_callback_tasks import (
|
||||
create_complaint_callback_data,
|
||||
create_delivery_status_callback_data,
|
||||
@@ -20,13 +20,10 @@ from app.dao.service_callback_api_dao import (
|
||||
get_service_complaint_callback_api_for_service,
|
||||
get_service_delivery_status_callback_api_for_service,
|
||||
)
|
||||
from app.enums import CallbackType, NotificationStatus
|
||||
from app.models import Complaint
|
||||
from app.models import NOTIFICATION_PENDING, NOTIFICATION_SENDING, Complaint
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="process-ses-result", max_retries=5, default_retry_delay=300
|
||||
)
|
||||
@notify_celery.task(bind=True, name="process-ses-result", max_retries=5, default_retry_delay=300)
|
||||
def process_ses_results(self, response):
|
||||
try:
|
||||
ses_message = json.loads(response["Message"])
|
||||
@@ -38,9 +35,9 @@ def process_ses_results(self, response):
|
||||
)
|
||||
bounce_message = None
|
||||
|
||||
if notification_type == "Bounce":
|
||||
if notification_type == 'Bounce':
|
||||
bounce_message = determine_notification_bounce_type(ses_message)
|
||||
elif notification_type == "Complaint":
|
||||
elif notification_type == 'Complaint':
|
||||
_check_and_queue_complaint_callback_task(*handle_complaint(ses_message))
|
||||
return True
|
||||
|
||||
@@ -50,13 +47,9 @@ def process_ses_results(self, response):
|
||||
reference = ses_message["mail"]["messageId"]
|
||||
|
||||
try:
|
||||
notification = notifications_dao.dao_get_notification_by_reference(
|
||||
reference
|
||||
)
|
||||
notification = notifications_dao.dao_get_notification_by_reference(reference)
|
||||
except NoResultFound:
|
||||
message_time = iso8601.parse_date(ses_message["mail"]["timestamp"]).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
message_time = iso8601.parse_date(ses_message["mail"]["timestamp"]).replace(tzinfo=None)
|
||||
if datetime.utcnow() - message_time < timedelta(minutes=5):
|
||||
current_app.logger.info(
|
||||
f"Notification not found for reference: {reference}"
|
||||
@@ -73,16 +66,12 @@ def process_ses_results(self, response):
|
||||
return
|
||||
|
||||
if bounce_message:
|
||||
current_app.logger.info(
|
||||
f"SES bounce for notification ID {notification.id}: {bounce_message}"
|
||||
)
|
||||
current_app.logger.info(f"SES bounce for notification ID {notification.id}: {bounce_message}")
|
||||
|
||||
if notification.status not in {
|
||||
NotificationStatus.SENDING,
|
||||
NotificationStatus.PENDING,
|
||||
}:
|
||||
if notification.status not in {NOTIFICATION_SENDING, NOTIFICATION_PENDING}:
|
||||
notifications_dao._duplicate_update_warning(
|
||||
notification, notification_status
|
||||
notification,
|
||||
notification_status
|
||||
)
|
||||
return
|
||||
|
||||
@@ -100,11 +89,14 @@ def process_ses_results(self, response):
|
||||
)
|
||||
else:
|
||||
current_app.logger.info(
|
||||
"SES callback return status of {} for notification: {}".format(
|
||||
notification_status, notification.id
|
||||
)
|
||||
"SES callback return status of {} for notification: {}".format(notification_status, notification.id)
|
||||
)
|
||||
|
||||
statsd_client.incr("callback.ses.{}".format(notification_status))
|
||||
|
||||
if notification.sent_at:
|
||||
statsd_client.timing_with_dates("callback.ses.elapsed-time", datetime.utcnow(), notification.sent_at)
|
||||
|
||||
check_and_queue_callback_task(notification)
|
||||
|
||||
return True
|
||||
@@ -126,11 +118,7 @@ def determine_notification_bounce_type(ses_message):
|
||||
raise KeyError(f"Unhandled sns notification type {notification_type}")
|
||||
|
||||
remove_emails_from_bounce(ses_message)
|
||||
current_app.logger.info(
|
||||
"SES bounce dict: {}".format(
|
||||
json.dumps(ses_message).replace("{", "(").replace("}", ")")
|
||||
)
|
||||
)
|
||||
current_app.logger.info("SES bounce dict: {}".format(json.dumps(ses_message).replace("{", "(").replace("}", ")")))
|
||||
if ses_message["bounce"]["bounceType"] == "Permanent":
|
||||
return "Permanent"
|
||||
return "Temporary"
|
||||
@@ -138,9 +126,9 @@ def determine_notification_bounce_type(ses_message):
|
||||
|
||||
def determine_notification_type(ses_message):
|
||||
notification_type = ses_message["notificationType"]
|
||||
if notification_type not in ["Bounce", "Complaint", "Delivery"]:
|
||||
if notification_type not in ["Bounce", "Complaint", "Delivery"]:
|
||||
raise KeyError(f"Unhandled sns notification type {notification_type}")
|
||||
if notification_type == "Bounce":
|
||||
if notification_type == 'Bounce':
|
||||
return determine_notification_bounce_type(ses_message)
|
||||
return notification_type
|
||||
|
||||
@@ -170,22 +158,22 @@ def get_aws_responses(ses_message):
|
||||
"Permanent": {
|
||||
"message": "Hard bounced",
|
||||
"success": False,
|
||||
"notification_status": NotificationStatus.PERMANENT_FAILURE,
|
||||
"notification_status": "permanent-failure",
|
||||
},
|
||||
"Temporary": {
|
||||
"message": "Soft bounced",
|
||||
"success": False,
|
||||
"notification_status": NotificationStatus.TEMPORARY_FAILURE,
|
||||
"notification_status": "temporary-failure",
|
||||
},
|
||||
"Delivery": {
|
||||
"message": "Delivered",
|
||||
"success": True,
|
||||
"notification_status": NotificationStatus.DELIVERED,
|
||||
"notification_status": "delivered",
|
||||
},
|
||||
"Complaint": {
|
||||
"message": "Complaint",
|
||||
"success": True,
|
||||
"notification_status": NotificationStatus.DELIVERED,
|
||||
"notification_status": "delivered",
|
||||
},
|
||||
}[status]
|
||||
|
||||
@@ -197,29 +185,21 @@ def get_aws_responses(ses_message):
|
||||
def handle_complaint(ses_message):
|
||||
recipient_email = remove_emails_from_complaint(ses_message)[0]
|
||||
current_app.logger.info(
|
||||
"Complaint from SES: \n{}".format(
|
||||
json.dumps(ses_message).replace("{", "(").replace("}", ")")
|
||||
)
|
||||
"Complaint from SES: \n{}".format(json.dumps(ses_message).replace("{", "(").replace("}", ")"))
|
||||
)
|
||||
try:
|
||||
reference = ses_message["mail"]["messageId"]
|
||||
except KeyError as e:
|
||||
current_app.logger.exception(
|
||||
f"Complaint from SES failed to get reference from message with error: {e}"
|
||||
)
|
||||
current_app.logger.exception(f"Complaint from SES failed to get reference from message with error: {e}")
|
||||
return
|
||||
notification = dao_get_notification_history_by_reference(reference)
|
||||
ses_complaint = ses_message.get(CallbackType.COMPLAINT, None)
|
||||
ses_complaint = ses_message.get("complaint", None)
|
||||
|
||||
complaint = Complaint(
|
||||
notification_id=notification.id,
|
||||
service_id=notification.service_id,
|
||||
ses_feedback_id=(
|
||||
ses_complaint.get("feedbackId", None) if ses_complaint else None
|
||||
),
|
||||
complaint_type=(
|
||||
ses_complaint.get("complaintFeedbackType", None) if ses_complaint else None
|
||||
),
|
||||
ses_feedback_id=ses_complaint.get("feedbackId", None) if ses_complaint else None,
|
||||
complaint_type=ses_complaint.get("complaintFeedbackType", None) if ses_complaint else None,
|
||||
complaint_date=ses_complaint.get("timestamp", None) if ses_complaint else None,
|
||||
)
|
||||
save_complaint(complaint)
|
||||
@@ -241,19 +221,15 @@ def remove_emails_from_bounce(bounce_dict):
|
||||
|
||||
def remove_emails_from_complaint(complaint_dict):
|
||||
remove_mail_headers(complaint_dict)
|
||||
complaint_dict[CallbackType.COMPLAINT].pop("complainedRecipients")
|
||||
complaint_dict["complaint"].pop("complainedRecipients")
|
||||
return complaint_dict["mail"].pop("destination")
|
||||
|
||||
|
||||
def check_and_queue_callback_task(notification):
|
||||
# queue callback task only if the service_callback_api exists
|
||||
service_callback_api = get_service_delivery_status_callback_api_for_service(
|
||||
service_id=notification.service_id
|
||||
)
|
||||
service_callback_api = get_service_delivery_status_callback_api_for_service(service_id=notification.service_id)
|
||||
if service_callback_api:
|
||||
notification_data = create_delivery_status_callback_data(
|
||||
notification, service_callback_api
|
||||
)
|
||||
notification_data = create_delivery_status_callback_data(notification, service_callback_api)
|
||||
send_delivery_status_to_service.apply_async(
|
||||
[str(notification.id), notification_data], queue=QueueNames.CALLBACKS
|
||||
)
|
||||
@@ -261,13 +237,7 @@ def check_and_queue_callback_task(notification):
|
||||
|
||||
def _check_and_queue_complaint_callback_task(complaint, notification, recipient):
|
||||
# queue callback task only if the service_callback_api exists
|
||||
service_callback_api = get_service_complaint_callback_api_for_service(
|
||||
service_id=notification.service_id
|
||||
)
|
||||
service_callback_api = get_service_complaint_callback_api_for_service(service_id=notification.service_id)
|
||||
if service_callback_api:
|
||||
complaint_data = create_complaint_callback_data(
|
||||
complaint, notification, service_callback_api, recipient
|
||||
)
|
||||
send_complaint_to_service.apply_async(
|
||||
[complaint_data], queue=QueueNames.CALLBACKS
|
||||
)
|
||||
complaint_data = create_complaint_callback_data(complaint, notification, service_callback_api, recipient)
|
||||
send_complaint_to_service.apply_async([complaint_data], queue=QueueNames.CALLBACKS)
|
||||
|
||||
@@ -1,135 +1,31 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from app import aws_cloudwatch_client, notify_celery, redis_store
|
||||
from app import notify_celery
|
||||
from app.clients.email import EmailClientNonRetryableException
|
||||
from app.clients.email.aws_ses import AwsSesClientThrottlingSendRateException
|
||||
from app.clients.sms import SmsClientResponseException
|
||||
from app.config import QueueNames
|
||||
from app.dao import notifications_dao
|
||||
from app.dao.notifications_dao import (
|
||||
sanitize_successful_notification_by_id,
|
||||
update_notification_status_by_id,
|
||||
)
|
||||
from app.dao.notifications_dao import update_notification_status_by_id
|
||||
from app.delivery import send_to_providers
|
||||
from app.enums import NotificationStatus
|
||||
from app.exceptions import NotificationTechnicalFailureException
|
||||
|
||||
# This is the amount of time to wait after sending an sms message before we check the aws logs and look for delivery
|
||||
# receipts
|
||||
DELIVERY_RECEIPT_DELAY_IN_SECONDS = 120
|
||||
from app.models import NOTIFICATION_TECHNICAL_FAILURE
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True,
|
||||
name="check_sms_delivery_receipt",
|
||||
max_retries=48,
|
||||
default_retry_delay=300,
|
||||
)
|
||||
def check_sms_delivery_receipt(self, message_id, notification_id, sent_at):
|
||||
"""
|
||||
This is called after deliver_sms to check the status of the message. This uses the same number of
|
||||
retries and the same delay period as deliver_sms. In addition, this fires five minutes after
|
||||
deliver_sms initially. So the idea is that most messages will succeed and show up in the logs quickly.
|
||||
Other message will resolve successfully after a retry or to. A few will fail but it will take up to
|
||||
4 hours to know for sure. The call to check_sms will raise an exception if neither a success nor a
|
||||
failure appears in the cloudwatch logs, so this should keep retrying until the log appears, or until
|
||||
we run out of retries.
|
||||
"""
|
||||
# TODO the localstack cloudwatch doesn't currently have our log groups. Possibly create them with awslocal?
|
||||
if aws_cloudwatch_client.is_localstack():
|
||||
status = "success"
|
||||
provider_response = "this is a fake successful localstack sms message"
|
||||
carrier = "unknown"
|
||||
else:
|
||||
try:
|
||||
status, provider_response, carrier = aws_cloudwatch_client.check_sms(
|
||||
message_id, notification_id, sent_at
|
||||
)
|
||||
except NotificationTechnicalFailureException as ntfe:
|
||||
provider_response = "Unable to find carrier response -- still looking"
|
||||
status = "pending"
|
||||
carrier = ""
|
||||
update_notification_status_by_id(
|
||||
notification_id,
|
||||
status,
|
||||
carrier=carrier,
|
||||
provider_response=provider_response,
|
||||
)
|
||||
raise self.retry(exc=ntfe)
|
||||
|
||||
if status == "success":
|
||||
status = NotificationStatus.DELIVERED
|
||||
elif status == "failure":
|
||||
status = NotificationStatus.FAILED
|
||||
# if status is not success or failure the client raised an exception and this method will retry
|
||||
|
||||
if status == NotificationStatus.DELIVERED:
|
||||
sanitize_successful_notification_by_id(
|
||||
notification_id, carrier=carrier, provider_response=provider_response
|
||||
)
|
||||
current_app.logger.info(
|
||||
f"Sanitized notification {notification_id} that was successfully delivered"
|
||||
)
|
||||
else:
|
||||
update_notification_status_by_id(
|
||||
notification_id,
|
||||
status,
|
||||
carrier=carrier,
|
||||
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
|
||||
)
|
||||
@notify_celery.task(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)
|
||||
)
|
||||
current_app.logger.info("Start sending SMS for notification id: {}".format(notification_id))
|
||||
notification = notifications_dao.get_notification_by_id(notification_id)
|
||||
ansi_green = "\033[32m"
|
||||
ansi_reset = "\033[0m"
|
||||
|
||||
if not notification:
|
||||
raise NoResultFound()
|
||||
if (
|
||||
os.getenv("NOTIFY_ENVIRONMENT") == "development"
|
||||
and "authentication code" in notification.content
|
||||
):
|
||||
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)
|
||||
my_eta = datetime.utcnow() + timedelta(
|
||||
seconds=DELIVERY_RECEIPT_DELAY_IN_SECONDS
|
||||
)
|
||||
check_sms_delivery_receipt.apply_async(
|
||||
[message_id, notification_id, notification.created_at],
|
||||
eta=my_eta,
|
||||
queue=QueueNames.CHECK_SMS,
|
||||
)
|
||||
send_to_providers.send_sms_to_provider(notification)
|
||||
except Exception as e:
|
||||
update_notification_status_by_id(
|
||||
notification_id,
|
||||
NotificationStatus.TEMPORARY_FAILURE,
|
||||
)
|
||||
if isinstance(e, SmsClientResponseException):
|
||||
current_app.logger.warning(
|
||||
"SMS notification delivery for id: {} failed".format(notification_id),
|
||||
exc_info=True,
|
||||
exc_info=True
|
||||
)
|
||||
else:
|
||||
current_app.logger.exception(
|
||||
@@ -142,40 +38,25 @@ def deliver_sms(self, notification_id):
|
||||
else:
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
message = (
|
||||
"RETRY FAILED: Max retries reached. The task send_sms_to_provider failed for notification {}. "
|
||||
"Notification has been updated to technical-failure".format(
|
||||
notification_id
|
||||
)
|
||||
)
|
||||
update_notification_status_by_id(
|
||||
notification_id,
|
||||
NotificationStatus.TECHNICAL_FAILURE,
|
||||
)
|
||||
message = "RETRY FAILED: Max retries reached. The task send_sms_to_provider failed for notification {}. " \
|
||||
"Notification has been updated to technical-failure".format(notification_id)
|
||||
update_notification_status_by_id(notification_id, NOTIFICATION_TECHNICAL_FAILURE)
|
||||
raise NotificationTechnicalFailureException(message)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="deliver_email", max_retries=48, default_retry_delay=300
|
||||
)
|
||||
@notify_celery.task(bind=True, name="deliver_email", max_retries=48, default_retry_delay=300)
|
||||
def deliver_email(self, notification_id):
|
||||
try:
|
||||
current_app.logger.info(
|
||||
"Start sending email for notification id: {}".format(notification_id)
|
||||
)
|
||||
current_app.logger.info("Start sending email for notification id: {}".format(notification_id))
|
||||
notification = notifications_dao.get_notification_by_id(notification_id)
|
||||
|
||||
if not notification:
|
||||
raise NoResultFound()
|
||||
personalisation = redis_store.get(f"email-personalisation-{notification_id}")
|
||||
|
||||
notification.personalisation = json.loads(personalisation)
|
||||
send_to_providers.send_email_to_provider(notification)
|
||||
except EmailClientNonRetryableException as e:
|
||||
current_app.logger.exception(
|
||||
f"Email notification {notification_id} failed: {e}"
|
||||
)
|
||||
update_notification_status_by_id(notification_id, "technical-failure")
|
||||
update_notification_status_by_id(notification_id, 'technical-failure')
|
||||
except Exception as e:
|
||||
try:
|
||||
if isinstance(e, AwsSesClientThrottlingSendRateException):
|
||||
@@ -189,15 +70,8 @@ def deliver_email(self, notification_id):
|
||||
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
message = (
|
||||
"RETRY FAILED: Max retries reached. "
|
||||
"The task send_email_to_provider failed for notification {}. "
|
||||
"Notification has been updated to technical-failure".format(
|
||||
notification_id
|
||||
)
|
||||
)
|
||||
update_notification_status_by_id(
|
||||
notification_id,
|
||||
NotificationStatus.TECHNICAL_FAILURE,
|
||||
)
|
||||
message = "RETRY FAILED: Max retries reached. " \
|
||||
"The task send_email_to_provider failed for notification {}. " \
|
||||
"Notification has been updated to technical-failure".format(notification_id)
|
||||
update_notification_status_by_id(notification_id, NOTIFICATION_TECHNICAL_FAILURE)
|
||||
raise NotificationTechnicalFailureException(message)
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.timezones import convert_utc_to_local_timezone
|
||||
|
||||
from app import notify_celery
|
||||
from app.config import QueueNames
|
||||
from app.cronitor import cronitor
|
||||
from app.dao.fact_billing_dao import fetch_billing_data_for_day, update_fact_billing
|
||||
from app.dao.fact_billing_dao import (
|
||||
fetch_billing_data_for_day,
|
||||
update_fact_billing,
|
||||
)
|
||||
from app.dao.fact_notification_status_dao import update_fact_notification_status
|
||||
from app.dao.notifications_dao import get_service_ids_with_notifications_on_date
|
||||
from app.enums import NotificationType
|
||||
from app.models import EMAIL_TYPE, SMS_TYPE
|
||||
|
||||
|
||||
@notify_celery.task(name="create-nightly-billing")
|
||||
@@ -17,7 +21,7 @@ def create_nightly_billing(day_start=None):
|
||||
# day_start is a datetime.date() object. e.g.
|
||||
# up to 4 days of data counting back from day_start is consolidated
|
||||
if day_start is None:
|
||||
day_start = datetime.utcnow().date() - timedelta(days=1)
|
||||
day_start = convert_utc_to_local_timezone(datetime.utcnow()).date() - timedelta(days=1)
|
||||
else:
|
||||
# When calling the task its a string in the format of "YYYY-MM-DD"
|
||||
day_start = datetime.strptime(day_start, "%Y-%m-%d").date()
|
||||
@@ -25,7 +29,8 @@ def create_nightly_billing(day_start=None):
|
||||
process_day = (day_start - timedelta(days=i)).isoformat()
|
||||
|
||||
create_nightly_billing_for_day.apply_async(
|
||||
kwargs={"process_day": process_day}, queue=QueueNames.REPORTING
|
||||
kwargs={'process_day': process_day},
|
||||
queue=QueueNames.REPORTING
|
||||
)
|
||||
current_app.logger.info(
|
||||
f"create-nightly-billing task: create-nightly-billing-for-day task created for {process_day}"
|
||||
@@ -36,7 +41,7 @@ def create_nightly_billing(day_start=None):
|
||||
def create_nightly_billing_for_day(process_day):
|
||||
process_day = datetime.strptime(process_day, "%Y-%m-%d").date()
|
||||
current_app.logger.info(
|
||||
f"create-nightly-billing-for-day task for {process_day}: started"
|
||||
f'create-nightly-billing-for-day task for {process_day}: started'
|
||||
)
|
||||
|
||||
start = datetime.utcnow()
|
||||
@@ -44,7 +49,7 @@ def create_nightly_billing_for_day(process_day):
|
||||
end = datetime.utcnow()
|
||||
|
||||
current_app.logger.info(
|
||||
f"create-nightly-billing-for-day task for {process_day}: data fetched in {(end - start).seconds} seconds"
|
||||
f'create-nightly-billing-for-day task for {process_day}: data fetched in {(end - start).seconds} seconds'
|
||||
)
|
||||
|
||||
for data in transit_data:
|
||||
@@ -78,9 +83,9 @@ def create_nightly_notification_status():
|
||||
mean the aggregated results are temporarily incorrect.
|
||||
"""
|
||||
|
||||
yesterday = datetime.utcnow().date() - timedelta(days=1)
|
||||
yesterday = convert_utc_to_local_timezone(datetime.utcnow()).date() - timedelta(days=1)
|
||||
|
||||
for notification_type in (NotificationType.SMS, NotificationType.EMAIL):
|
||||
for notification_type in [SMS_TYPE, EMAIL_TYPE]:
|
||||
days = 4
|
||||
|
||||
for i in range(days):
|
||||
@@ -93,30 +98,28 @@ def create_nightly_notification_status():
|
||||
for service_id in relevant_service_ids:
|
||||
create_nightly_notification_status_for_service_and_day.apply_async(
|
||||
kwargs={
|
||||
"process_day": process_day.isoformat(),
|
||||
"notification_type": notification_type,
|
||||
"service_id": service_id,
|
||||
'process_day': process_day.isoformat(),
|
||||
'notification_type': notification_type,
|
||||
'service_id': service_id,
|
||||
},
|
||||
queue=QueueNames.REPORTING,
|
||||
queue=QueueNames.REPORTING
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name="create-nightly-notification-status-for-service-and-day")
|
||||
def create_nightly_notification_status_for_service_and_day(
|
||||
process_day, service_id, notification_type
|
||||
):
|
||||
def create_nightly_notification_status_for_service_and_day(process_day, service_id, notification_type):
|
||||
process_day = datetime.strptime(process_day, "%Y-%m-%d").date()
|
||||
|
||||
start = datetime.utcnow()
|
||||
update_fact_notification_status(
|
||||
process_day=process_day,
|
||||
notification_type=notification_type,
|
||||
service_id=service_id,
|
||||
service_id=service_id
|
||||
)
|
||||
|
||||
end = datetime.utcnow()
|
||||
current_app.logger.info(
|
||||
f"create-nightly-notification-status-for-service-and-day task update "
|
||||
f"for {service_id}, {notification_type} for {process_day}: "
|
||||
f"updated in {(end - start).seconds} seconds"
|
||||
f'create-nightly-notification-status-for-service-and-day task update '
|
||||
f'for {service_id}, {notification_type} for {process_day}: '
|
||||
f'updated in {(end - start).seconds} seconds'
|
||||
)
|
||||
|
||||
@@ -5,8 +5,7 @@ from requests import HTTPError, request
|
||||
|
||||
from app.celery.process_ses_receipts_tasks import process_ses_results
|
||||
from app.config import QueueNames
|
||||
from app.dao.notifications_dao import get_notification_by_id
|
||||
from app.enums import NotificationType
|
||||
from app.models import SMS_TYPE
|
||||
|
||||
temp_fail = "2028675303"
|
||||
perm_fail = "2028675302"
|
||||
@@ -17,11 +16,11 @@ perm_fail_email = "perm-fail@simulator.notify"
|
||||
temp_fail_email = "temp-fail@simulator.notify"
|
||||
|
||||
|
||||
def send_sms_response(provider, reference):
|
||||
body = sns_callback(reference)
|
||||
def send_sms_response(provider, reference, to):
|
||||
body = sns_callback(reference, to)
|
||||
headers = {"Content-type": "application/json"}
|
||||
|
||||
make_request(NotificationType.SMS, provider, body, headers)
|
||||
make_request(SMS_TYPE, provider, body, headers)
|
||||
|
||||
|
||||
def send_email_response(reference, to):
|
||||
@@ -36,17 +35,22 @@ def send_email_response(reference, to):
|
||||
|
||||
|
||||
def make_request(notification_type, provider, data, headers):
|
||||
api_call = "{}/notifications/{}/{}".format(
|
||||
current_app.config["API_HOST_NAME"], notification_type, provider
|
||||
)
|
||||
api_call = "{}/notifications/{}/{}".format(current_app.config["API_HOST_NAME"], notification_type, provider)
|
||||
|
||||
try:
|
||||
response = request("POST", api_call, headers=headers, data=data, timeout=60)
|
||||
response = request(
|
||||
"POST",
|
||||
api_call,
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
except HTTPError as e:
|
||||
current_app.logger.error(
|
||||
"API POST request on {} failed with status {}".format(
|
||||
api_call, e.response.status_code
|
||||
api_call,
|
||||
e.response.status_code
|
||||
)
|
||||
)
|
||||
raise e
|
||||
@@ -55,136 +59,165 @@ def make_request(notification_type, provider, data, headers):
|
||||
return response.json()
|
||||
|
||||
|
||||
def sns_callback(notification_id):
|
||||
notification = get_notification_by_id(notification_id)
|
||||
def sns_callback(notification_id, to):
|
||||
raise Exception("Need to update for SNS callback format along with test_send_to_providers")
|
||||
|
||||
# This will only work if all notifications, including successful ones, are in the notifications table
|
||||
# If we decide to delete successful notifications, we will have to get this from notifications history
|
||||
return json.dumps(
|
||||
{
|
||||
"CID": str(notification_id),
|
||||
"status": notification.status,
|
||||
# "deliverytime": notification.completed_at
|
||||
}
|
||||
)
|
||||
# example from mmg_callback
|
||||
# if to.strip().endswith(temp_fail):
|
||||
# # status: 4 - expired (temp failure)
|
||||
# status = "4"
|
||||
# elif to.strip().endswith(perm_fail):
|
||||
# # status: 5 - rejected (perm failure)
|
||||
# status = "5"
|
||||
# else:
|
||||
# # status: 3 - delivered
|
||||
# status = "3"
|
||||
|
||||
# return json.dumps({"reference": "mmg_reference",
|
||||
# "CID": str(notification_id),
|
||||
# "MSISDN": to,
|
||||
# "status": status,
|
||||
# "deliverytime": "2016-04-05 16:01:07"})
|
||||
|
||||
|
||||
def ses_notification_callback(reference):
|
||||
ses_message_body = {
|
||||
"delivery": {
|
||||
"processingTimeMillis": 2003,
|
||||
"recipients": ["success@simulator.amazonses.com"],
|
||||
"remoteMtaIp": "123.123.123.123",
|
||||
"reportingMTA": "a7-32.smtp-out.us-west-2.amazonses.com",
|
||||
"smtpResponse": "250 2.6.0 Message received",
|
||||
"timestamp": "2017-11-17T12:14:03.646Z",
|
||||
'delivery': {
|
||||
'processingTimeMillis': 2003,
|
||||
'recipients': ['success@simulator.amazonses.com'],
|
||||
'remoteMtaIp': '123.123.123.123',
|
||||
'reportingMTA': 'a7-32.smtp-out.us-west-2.amazonses.com',
|
||||
'smtpResponse': '250 2.6.0 Message received',
|
||||
'timestamp': '2017-11-17T12:14:03.646Z'
|
||||
},
|
||||
"mail": {
|
||||
"commonHeaders": {
|
||||
"from": ["TEST <TEST@notify.works>"],
|
||||
"subject": "lambda test",
|
||||
"to": ["success@simulator.amazonses.com"],
|
||||
'mail': {
|
||||
'commonHeaders': {
|
||||
'from': ['TEST <TEST@notify.works>'],
|
||||
'subject': 'lambda test',
|
||||
'to': ['success@simulator.amazonses.com']
|
||||
},
|
||||
"destination": ["success@simulator.amazonses.com"],
|
||||
"headers": [
|
||||
{"name": "From", "value": "TEST <TEST@notify.works>"},
|
||||
{"name": "To", "value": "success@simulator.amazonses.com"},
|
||||
{"name": "Subject", "value": "lambda test"},
|
||||
{"name": "MIME-Version", "value": "1.0"},
|
||||
'destination': ['success@simulator.amazonses.com'],
|
||||
'headers': [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": 'multipart/alternative; boundary="----=_Part_617203_1627511946.1510920841645"',
|
||||
'name': 'From',
|
||||
'value': 'TEST <TEST@notify.works>'
|
||||
},
|
||||
{
|
||||
'name': 'To',
|
||||
'value': 'success@simulator.amazonses.com'
|
||||
},
|
||||
{
|
||||
'name': 'Subject',
|
||||
'value': 'lambda test'
|
||||
},
|
||||
{
|
||||
'name': 'MIME-Version',
|
||||
'value': '1.0'
|
||||
},
|
||||
{
|
||||
'name': 'Content-Type',
|
||||
'value': 'multipart/alternative; boundary="----=_Part_617203_1627511946.1510920841645"'
|
||||
}
|
||||
],
|
||||
"headersTruncated": False,
|
||||
"messageId": reference,
|
||||
"sendingAccountId": "12341234",
|
||||
"source": '"TEST" <TEST@notify.works>',
|
||||
"sourceArn": "arn:aws:ses:us-west-2:12341234:identity/notify.works",
|
||||
"sourceIp": "0.0.0.1",
|
||||
"timestamp": "2017-11-17T12:14:01.643Z",
|
||||
'headersTruncated': False,
|
||||
'messageId': reference,
|
||||
'sendingAccountId': '12341234',
|
||||
'source': '"TEST" <TEST@notify.works>',
|
||||
'sourceArn': 'arn:aws:ses:us-west-2:12341234:identity/notify.works',
|
||||
'sourceIp': '0.0.0.1',
|
||||
'timestamp': '2017-11-17T12:14:01.643Z'
|
||||
},
|
||||
"notificationType": "Delivery",
|
||||
'notificationType': 'Delivery'
|
||||
}
|
||||
|
||||
return {
|
||||
"Type": "Notification",
|
||||
"MessageId": "8e83c020-1234-1234-1234-92a8ee9baa0a",
|
||||
"TopicArn": "arn:aws:sns:us-west-2:12341234:ses_notifications",
|
||||
"Subject": None,
|
||||
"Message": json.dumps(ses_message_body),
|
||||
"Timestamp": "2017-11-17T12:14:03.710Z",
|
||||
"SignatureVersion": "1",
|
||||
"Signature": "[REDACTED]",
|
||||
"SigningCertUrl": "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-[REDACTED].pem",
|
||||
"UnsubscribeUrl": "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REACTED]",
|
||||
"MessageAttributes": {},
|
||||
'Type': 'Notification',
|
||||
'MessageId': '8e83c020-1234-1234-1234-92a8ee9baa0a',
|
||||
'TopicArn': 'arn:aws:sns:us-west-2:12341234:ses_notifications',
|
||||
'Subject': None,
|
||||
'Message': json.dumps(ses_message_body),
|
||||
'Timestamp': '2017-11-17T12:14:03.710Z',
|
||||
'SignatureVersion': '1',
|
||||
'Signature': '[REDACTED]',
|
||||
'SigningCertUrl': 'https://sns.us-west-2.amazonaws.com/SimpleNotificationService-[REDACTED].pem',
|
||||
'UnsubscribeUrl': 'https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REACTED]',
|
||||
'MessageAttributes': {}
|
||||
}
|
||||
|
||||
|
||||
def ses_hard_bounce_callback(reference):
|
||||
return _ses_bounce_callback(reference, "Permanent")
|
||||
return _ses_bounce_callback(reference, 'Permanent')
|
||||
|
||||
|
||||
def ses_soft_bounce_callback(reference):
|
||||
return _ses_bounce_callback(reference, "Temporary")
|
||||
return _ses_bounce_callback(reference, 'Temporary')
|
||||
|
||||
|
||||
def _ses_bounce_callback(reference, bounce_type):
|
||||
ses_message_body = {
|
||||
"bounce": {
|
||||
"bounceSubType": "General",
|
||||
"bounceType": bounce_type,
|
||||
"bouncedRecipients": [
|
||||
'bounce': {
|
||||
'bounceSubType': 'General',
|
||||
'bounceType': bounce_type,
|
||||
'bouncedRecipients': [{
|
||||
'action': 'failed',
|
||||
'diagnosticCode': 'smtp; 550 5.1.1 user unknown',
|
||||
'emailAddress': 'bounce@simulator.amazonses.com',
|
||||
'status': '5.1.1'
|
||||
}],
|
||||
'feedbackId': '0102015fc9e676fb-12341234-1234-1234-1234-9301e86a4fa8-000000',
|
||||
'remoteMtaIp': '123.123.123.123',
|
||||
'reportingMTA': 'dsn; a7-31.smtp-out.us-west-2.amazonses.com',
|
||||
'timestamp': '2017-11-17T12:14:05.131Z'
|
||||
},
|
||||
'mail': {
|
||||
'commonHeaders': {
|
||||
'from': ['TEST <TEST@notify.works>'],
|
||||
'subject': 'ses callback test',
|
||||
'to': ['bounce@simulator.amazonses.com']
|
||||
},
|
||||
'destination': ['bounce@simulator.amazonses.com'],
|
||||
'headers': [
|
||||
{
|
||||
"action": "failed",
|
||||
"diagnosticCode": "smtp; 550 5.1.1 user unknown",
|
||||
"emailAddress": "bounce@simulator.amazonses.com",
|
||||
"status": "5.1.1",
|
||||
'name': 'From',
|
||||
'value': 'TEST <TEST@notify.works>'
|
||||
},
|
||||
{
|
||||
'name': 'To',
|
||||
'value': 'bounce@simulator.amazonses.com'
|
||||
},
|
||||
{
|
||||
'name': 'Subject',
|
||||
'value': 'lambda test'
|
||||
},
|
||||
{
|
||||
'name': 'MIME-Version',
|
||||
'value': '1.0'
|
||||
},
|
||||
{
|
||||
'name': 'Content-Type',
|
||||
'value': 'multipart/alternative; boundary="----=_Part_596529_2039165601.1510920843367"'
|
||||
}
|
||||
],
|
||||
"feedbackId": "0102015fc9e676fb-12341234-1234-1234-1234-9301e86a4fa8-000000",
|
||||
"remoteMtaIp": "123.123.123.123",
|
||||
"reportingMTA": "dsn; a7-31.smtp-out.us-west-2.amazonses.com",
|
||||
"timestamp": "2017-11-17T12:14:05.131Z",
|
||||
'headersTruncated': False,
|
||||
'messageId': reference,
|
||||
'sendingAccountId': '12341234',
|
||||
'source': '"TEST" <TEST@notify.works>',
|
||||
'sourceArn': 'arn:aws:ses:us-west-2:12341234:identity/notify.works',
|
||||
'sourceIp': '0.0.0.1',
|
||||
'timestamp': '2017-11-17T12:14:03.000Z'
|
||||
},
|
||||
"mail": {
|
||||
"commonHeaders": {
|
||||
"from": ["TEST <TEST@notify.works>"],
|
||||
"subject": "ses callback test",
|
||||
"to": ["bounce@simulator.amazonses.com"],
|
||||
},
|
||||
"destination": ["bounce@simulator.amazonses.com"],
|
||||
"headers": [
|
||||
{"name": "From", "value": "TEST <TEST@notify.works>"},
|
||||
{"name": "To", "value": "bounce@simulator.amazonses.com"},
|
||||
{"name": "Subject", "value": "lambda test"},
|
||||
{"name": "MIME-Version", "value": "1.0"},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": 'multipart/alternative; boundary="----=_Part_596529_2039165601.1510920843367"',
|
||||
},
|
||||
],
|
||||
"headersTruncated": False,
|
||||
"messageId": reference,
|
||||
"sendingAccountId": "12341234",
|
||||
"source": '"TEST" <TEST@notify.works>',
|
||||
"sourceArn": "arn:aws:ses:us-west-2:12341234:identity/notify.works",
|
||||
"sourceIp": "0.0.0.1",
|
||||
"timestamp": "2017-11-17T12:14:03.000Z",
|
||||
},
|
||||
"notificationType": "Bounce",
|
||||
'notificationType': 'Bounce'
|
||||
}
|
||||
return {
|
||||
"Type": "Notification",
|
||||
"MessageId": "36e67c28-1234-1234-1234-2ea0172aa4a7",
|
||||
"TopicArn": "arn:aws:sns:us-west-2:12341234:ses_notifications",
|
||||
"Subject": None,
|
||||
"Message": json.dumps(ses_message_body),
|
||||
"Timestamp": "2017-11-17T12:14:05.149Z",
|
||||
"SignatureVersion": "1",
|
||||
"Signature": "[REDACTED]", # noqa
|
||||
"SigningCertUrl": "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-[REDACTED]].pem",
|
||||
"UnsubscribeUrl": "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REDACTED]]",
|
||||
"MessageAttributes": {},
|
||||
'Type': 'Notification',
|
||||
'MessageId': '36e67c28-1234-1234-1234-2ea0172aa4a7',
|
||||
'TopicArn': 'arn:aws:sns:us-west-2:12341234:ses_notifications',
|
||||
'Subject': None,
|
||||
'Message': json.dumps(ses_message_body),
|
||||
'Timestamp': '2017-11-17T12:14:05.149Z',
|
||||
'SignatureVersion': '1',
|
||||
'Signature': '[REDACTED]', # noqa
|
||||
'SigningCertUrl': 'https://sns.us-west-2.amazonaws.com/SimpleNotificationService-[REDACTED]].pem',
|
||||
'UnsubscribeUrl': 'https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REDACTED]]',
|
||||
'MessageAttributes': {}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.clients.zendesk.zendesk_client import NotifySupportTicket
|
||||
from notifications_utils.clients.zendesk.zendesk_client import (
|
||||
NotifySupportTicket,
|
||||
)
|
||||
from sqlalchemy import between
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app import notify_celery, redis_store, zendesk_client
|
||||
from app import notify_celery, zendesk_client
|
||||
from app.celery.tasks import (
|
||||
get_recipient_csv_and_template_and_sender_id,
|
||||
process_incomplete_jobs,
|
||||
@@ -17,38 +18,41 @@ from app.config import QueueNames
|
||||
from app.dao.invited_org_user_dao import (
|
||||
delete_org_invitations_created_more_than_two_days_ago,
|
||||
)
|
||||
from app.dao.invited_user_dao import expire_invitations_created_more_than_two_days_ago
|
||||
from app.dao.invited_user_dao import (
|
||||
delete_invitations_created_more_than_two_days_ago,
|
||||
)
|
||||
from app.dao.jobs_dao import (
|
||||
dao_set_scheduled_jobs_to_pending,
|
||||
dao_update_job,
|
||||
find_jobs_with_missing_rows,
|
||||
find_missing_row_for_job,
|
||||
)
|
||||
from app.dao.notifications_dao import (
|
||||
dao_get_failed_notification_count,
|
||||
notifications_not_yet_sent,
|
||||
from app.dao.notifications_dao import notifications_not_yet_sent
|
||||
from app.dao.provider_details_dao import (
|
||||
dao_adjust_provider_priority_back_to_resting_points,
|
||||
)
|
||||
from app.dao.services_dao import (
|
||||
dao_find_services_sending_to_tv_numbers,
|
||||
dao_find_services_with_high_failure_rates,
|
||||
)
|
||||
from app.dao.users_dao import delete_codes_older_created_more_than_a_day_ago
|
||||
from app.delivery.send_to_providers import provider_to_use
|
||||
from app.enums import JobStatus, NotificationType
|
||||
from app.models import Job
|
||||
from app.models import (
|
||||
EMAIL_TYPE,
|
||||
JOB_STATUS_ERROR,
|
||||
JOB_STATUS_IN_PROGRESS,
|
||||
JOB_STATUS_PENDING,
|
||||
SMS_TYPE,
|
||||
Job,
|
||||
)
|
||||
from app.notifications.process_notifications import send_notification_to_queue
|
||||
|
||||
MAX_NOTIFICATION_FAILS = 10000
|
||||
|
||||
|
||||
@notify_celery.task(name="run-scheduled-jobs")
|
||||
def run_scheduled_jobs():
|
||||
try:
|
||||
for job in dao_set_scheduled_jobs_to_pending():
|
||||
process_job.apply_async([str(job.id)], queue=QueueNames.JOBS)
|
||||
current_app.logger.info(
|
||||
"Job ID {} added to process job queue".format(job.id)
|
||||
)
|
||||
current_app.logger.info("Job ID {} added to process job queue".format(job.id))
|
||||
except SQLAlchemyError:
|
||||
current_app.logger.exception("Failed to run scheduled jobs")
|
||||
raise
|
||||
@@ -60,115 +64,33 @@ def delete_verify_codes():
|
||||
start = datetime.utcnow()
|
||||
deleted = delete_codes_older_created_more_than_a_day_ago()
|
||||
current_app.logger.info(
|
||||
"Delete job started {} finished {} deleted {} verify codes".format(
|
||||
start, datetime.utcnow(), deleted
|
||||
)
|
||||
"Delete job started {} finished {} deleted {} verify codes".format(start, datetime.utcnow(), deleted)
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
current_app.logger.exception("Failed to delete verify codes")
|
||||
raise
|
||||
|
||||
|
||||
@notify_celery.task(name="expire-or-delete-invitations")
|
||||
def expire_or_delete_invitations():
|
||||
@notify_celery.task(name="delete-invitations")
|
||||
def delete_invitations():
|
||||
try:
|
||||
start = datetime.utcnow()
|
||||
expired_invites = expire_invitations_created_more_than_two_days_ago()
|
||||
deleted_invites = delete_invitations_created_more_than_two_days_ago()
|
||||
deleted_invites += delete_org_invitations_created_more_than_two_days_ago()
|
||||
current_app.logger.info(
|
||||
f"Expire job started {start} finished {datetime.utcnow()} expired {expired_invites} invitations"
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
current_app.logger.exception("Failed to expire invitations")
|
||||
raise
|
||||
|
||||
try:
|
||||
start = datetime.utcnow()
|
||||
deleted_invites = delete_org_invitations_created_more_than_two_days_ago()
|
||||
current_app.logger.info(
|
||||
f"Delete job started {start} finished {datetime.utcnow()} deleted {deleted_invites} invitations"
|
||||
"Delete job started {} finished {} deleted {} invitations".format(start, datetime.utcnow(), deleted_invites)
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
current_app.logger.exception("Failed to delete invitations")
|
||||
raise
|
||||
|
||||
|
||||
# TODO THIS IS ACTUALLY DEPRECATED, WE ARE REMOVING PHONE NUMBERS FROM THE DB
|
||||
# SO THERE WILL BE NO REASON TO KEEP TRACK OF THIS COUNT
|
||||
@notify_celery.task(name="check-db-notification-fails")
|
||||
def check_db_notification_fails():
|
||||
"""
|
||||
We are going to use redis to keep track of the previous fail count.
|
||||
|
||||
If the number of fails is more than 100% of the limit, we want to send an alert every time this
|
||||
runs, because it is urgent to fix it.
|
||||
|
||||
If the number is more than 25%, 50% or 75% of the limit, we only want to send an alert
|
||||
on a breach. I.e., if the last number was at 23% and the current number is 27%, send an email.
|
||||
But if the last number was 26% and the current is 27%, don't.
|
||||
"""
|
||||
last_value = redis_store.get("LAST_DB_NOTIFICATION_COUNT")
|
||||
if not last_value:
|
||||
last_value = 0
|
||||
else:
|
||||
last_value = int(last_value.decode("utf-8"))
|
||||
|
||||
failed_count = dao_get_failed_notification_count()
|
||||
if failed_count > last_value:
|
||||
redis_store.set("LAST_DB_NOTIFICATION_COUNT", failed_count)
|
||||
message = ""
|
||||
curr_env = os.getenv("ENVIRONMENT")
|
||||
if failed_count >= MAX_NOTIFICATION_FAILS:
|
||||
message = f"We are over 100% in the db for failed notifications on {curr_env}"
|
||||
elif (
|
||||
failed_count >= MAX_NOTIFICATION_FAILS * 0.9
|
||||
and last_value < MAX_NOTIFICATION_FAILS * 0.9
|
||||
):
|
||||
message = (
|
||||
"tts-notify-alerts@gsa.gov",
|
||||
f"We crossed above 90% in the db for failed notifications on {curr_env}",
|
||||
)
|
||||
|
||||
elif (
|
||||
failed_count >= MAX_NOTIFICATION_FAILS * 0.75
|
||||
and last_value < MAX_NOTIFICATION_FAILS * 0.75
|
||||
):
|
||||
message = (
|
||||
"tts-notify-alerts@gsa.gov",
|
||||
f"We crossed above 75% in the db for failed notifications on {curr_env}",
|
||||
)
|
||||
elif (
|
||||
failed_count >= MAX_NOTIFICATION_FAILS * 0.5
|
||||
and last_value < MAX_NOTIFICATION_FAILS * 0.5
|
||||
):
|
||||
message = (
|
||||
"tts-notify-alerts@gsa.gov",
|
||||
f"We crossed above 50% in the db for failed notifications on {curr_env}",
|
||||
)
|
||||
elif (
|
||||
failed_count >= MAX_NOTIFICATION_FAILS * 0.25
|
||||
and last_value < MAX_NOTIFICATION_FAILS * 0.25
|
||||
):
|
||||
message = (
|
||||
"tts-notify-alerts@gsa.gov",
|
||||
f"We crossed above 25% in the db for failed notifications on {curr_env}",
|
||||
)
|
||||
# suppress any spam coming from development tier
|
||||
if message and curr_env != "development":
|
||||
provider = provider_to_use(NotificationType.EMAIL, False)
|
||||
from_address = '"{}" <{}@{}>'.format(
|
||||
"Failed Notification Count Alert",
|
||||
"test_sender",
|
||||
current_app.config["NOTIFY_EMAIL_DOMAIN"],
|
||||
)
|
||||
provider.send_email(
|
||||
from_address,
|
||||
"tts-notify-alerts@gsa.gov",
|
||||
"DB Notification Failures Level Breached",
|
||||
body=str(message),
|
||||
)
|
||||
@notify_celery.task(name='tend-providers-back-to-middle')
|
||||
def tend_providers_back_to_middle():
|
||||
dao_adjust_provider_priority_back_to_resting_points()
|
||||
|
||||
|
||||
@notify_celery.task(name="check-job-status")
|
||||
@notify_celery.task(name='check-job-status')
|
||||
def check_job_status():
|
||||
"""
|
||||
every x minutes do this check
|
||||
@@ -186,90 +108,77 @@ def check_job_status():
|
||||
thirty_five_minutes_ago = datetime.utcnow() - timedelta(minutes=35)
|
||||
|
||||
incomplete_in_progress_jobs = Job.query.filter(
|
||||
Job.job_status == JobStatus.IN_PROGRESS,
|
||||
between(Job.processing_started, thirty_five_minutes_ago, thirty_minutes_ago),
|
||||
Job.job_status == JOB_STATUS_IN_PROGRESS,
|
||||
between(Job.processing_started, thirty_five_minutes_ago, thirty_minutes_ago)
|
||||
)
|
||||
incomplete_pending_jobs = Job.query.filter(
|
||||
Job.job_status == JobStatus.PENDING,
|
||||
Job.job_status == JOB_STATUS_PENDING,
|
||||
Job.scheduled_for.isnot(None),
|
||||
between(Job.scheduled_for, thirty_five_minutes_ago, thirty_minutes_ago),
|
||||
between(Job.scheduled_for, thirty_five_minutes_ago, thirty_minutes_ago)
|
||||
)
|
||||
|
||||
jobs_not_complete_after_30_minutes = (
|
||||
incomplete_in_progress_jobs.union(incomplete_pending_jobs)
|
||||
.order_by(Job.processing_started, Job.scheduled_for)
|
||||
.all()
|
||||
)
|
||||
jobs_not_complete_after_30_minutes = incomplete_in_progress_jobs.union(
|
||||
incomplete_pending_jobs
|
||||
).order_by(
|
||||
Job.processing_started, Job.scheduled_for
|
||||
).all()
|
||||
|
||||
# temporarily mark them as ERROR so that they don't get picked up by future check_job_status tasks
|
||||
# if they haven't been re-processed in time.
|
||||
job_ids = []
|
||||
for job in jobs_not_complete_after_30_minutes:
|
||||
job.job_status = JobStatus.ERROR
|
||||
job.job_status = JOB_STATUS_ERROR
|
||||
dao_update_job(job)
|
||||
job_ids.append(str(job.id))
|
||||
|
||||
if job_ids:
|
||||
current_app.logger.info("Job(s) {} have not completed.".format(job_ids))
|
||||
process_incomplete_jobs.apply_async([job_ids], queue=QueueNames.JOBS)
|
||||
process_incomplete_jobs.apply_async(
|
||||
[job_ids],
|
||||
queue=QueueNames.JOBS
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name="replay-created-notifications")
|
||||
@notify_celery.task(name='replay-created-notifications')
|
||||
def replay_created_notifications():
|
||||
# if the notification has not be send after 1 hour, then try to resend.
|
||||
resend_created_notifications_older_than = 60 * 60
|
||||
for notification_type in (NotificationType.EMAIL, NotificationType.SMS):
|
||||
resend_created_notifications_older_than = (60 * 60)
|
||||
for notification_type in (EMAIL_TYPE, SMS_TYPE):
|
||||
notifications_to_resend = notifications_not_yet_sent(
|
||||
resend_created_notifications_older_than, notification_type
|
||||
resend_created_notifications_older_than,
|
||||
notification_type
|
||||
)
|
||||
|
||||
if len(notifications_to_resend) > 0:
|
||||
current_app.logger.info(
|
||||
"Sending {} {} notifications "
|
||||
"to the delivery queue because the notification "
|
||||
"status was created.".format(
|
||||
len(notifications_to_resend), notification_type
|
||||
)
|
||||
)
|
||||
current_app.logger.info("Sending {} {} notifications "
|
||||
"to the delivery queue because the notification "
|
||||
"status was created.".format(len(notifications_to_resend), notification_type))
|
||||
|
||||
for n in notifications_to_resend:
|
||||
send_notification_to_queue(notification=n)
|
||||
send_notification_to_queue(notification=n, research_mode=n.service.research_mode)
|
||||
|
||||
|
||||
@notify_celery.task(name="check-for-missing-rows-in-completed-jobs")
|
||||
@notify_celery.task(name='check-for-missing-rows-in-completed-jobs')
|
||||
def check_for_missing_rows_in_completed_jobs():
|
||||
jobs = find_jobs_with_missing_rows()
|
||||
for job in jobs:
|
||||
(
|
||||
recipient_csv,
|
||||
template,
|
||||
sender_id,
|
||||
) = get_recipient_csv_and_template_and_sender_id(job)
|
||||
recipient_csv, template, sender_id = get_recipient_csv_and_template_and_sender_id(job)
|
||||
missing_rows = find_missing_row_for_job(job.id, job.notification_count)
|
||||
for row_to_process in missing_rows:
|
||||
row = recipient_csv[row_to_process.missing_row]
|
||||
current_app.logger.info(
|
||||
"Processing missing row: {} for job: {}".format(
|
||||
row_to_process.missing_row, job.id
|
||||
)
|
||||
)
|
||||
"Processing missing row: {} for job: {}".format(row_to_process.missing_row, job.id))
|
||||
process_row(row, template, job, job.service, sender_id=sender_id)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
name="check-for-services-with-high-failure-rates-or-sending-to-tv-numbers"
|
||||
)
|
||||
@notify_celery.task(name='check-for-services-with-high-failure-rates-or-sending-to-tv-numbers')
|
||||
def check_for_services_with_high_failure_rates_or_sending_to_tv_numbers():
|
||||
start_date = datetime.utcnow() - timedelta(days=1)
|
||||
start_date = (datetime.utcnow() - timedelta(days=1))
|
||||
end_date = datetime.utcnow()
|
||||
message = ""
|
||||
|
||||
services_with_failures = dao_find_services_with_high_failure_rates(
|
||||
start_date=start_date, end_date=end_date
|
||||
)
|
||||
services_sending_to_tv_numbers = dao_find_services_sending_to_tv_numbers(
|
||||
start_date=start_date, end_date=end_date
|
||||
)
|
||||
services_with_failures = dao_find_services_with_high_failure_rates(start_date=start_date, end_date=end_date)
|
||||
services_sending_to_tv_numbers = dao_find_services_sending_to_tv_numbers(start_date=start_date, end_date=end_date)
|
||||
|
||||
if services_with_failures:
|
||||
message += "{} service(s) have had high permanent-failure rates for sms messages in last 24 hours:\n".format(
|
||||
@@ -277,19 +186,17 @@ def check_for_services_with_high_failure_rates_or_sending_to_tv_numbers():
|
||||
)
|
||||
for service in services_with_failures:
|
||||
service_dashboard = "{}/services/{}".format(
|
||||
current_app.config["ADMIN_BASE_URL"],
|
||||
current_app.config['ADMIN_BASE_URL'],
|
||||
str(service.service_id),
|
||||
)
|
||||
message += "service: {} failure rate: {},\n".format(
|
||||
service_dashboard, service.permanent_failure_rate
|
||||
)
|
||||
message += "service: {} failure rate: {},\n".format(service_dashboard, service.permanent_failure_rate)
|
||||
elif services_sending_to_tv_numbers:
|
||||
message += "{} service(s) have sent over 500 sms messages to tv numbers in last 24 hours:\n".format(
|
||||
len(services_sending_to_tv_numbers)
|
||||
)
|
||||
for service in services_sending_to_tv_numbers:
|
||||
service_dashboard = "{}/services/{}".format(
|
||||
current_app.config["ADMIN_BASE_URL"],
|
||||
current_app.config['ADMIN_BASE_URL'],
|
||||
str(service.service_id),
|
||||
)
|
||||
message += "service: {} count of sms to tv numbers: {},\n".format(
|
||||
@@ -299,15 +206,13 @@ def check_for_services_with_high_failure_rates_or_sending_to_tv_numbers():
|
||||
if services_with_failures or services_sending_to_tv_numbers:
|
||||
current_app.logger.warning(message)
|
||||
|
||||
if current_app.config["NOTIFY_ENVIRONMENT"] in ["live", "production", "test"]:
|
||||
message += (
|
||||
"\nYou can find instructions for this ticket in our manual:\n"
|
||||
"https://github.com/alphagov/notifications-manuals/wiki/Support-Runbook#Deal-with-services-with-high-failure-rates-or-sending-sms-to-tv-numbers" # noqa
|
||||
)
|
||||
if current_app.config['NOTIFY_ENVIRONMENT'] in ['live', 'production', 'test']:
|
||||
message += ("\nYou can find instructions for this ticket in our manual:\n"
|
||||
"https://github.com/alphagov/notifications-manuals/wiki/Support-Runbook#Deal-with-services-with-high-failure-rates-or-sending-sms-to-tv-numbers") # noqa
|
||||
ticket = NotifySupportTicket(
|
||||
subject=f"[{current_app.config['NOTIFY_ENVIRONMENT']}] High failure rates for sms spotted for services",
|
||||
message=message,
|
||||
ticket_type=NotifySupportTicket.TYPE_INCIDENT,
|
||||
technical_ticket=True,
|
||||
technical_ticket=True
|
||||
)
|
||||
zendesk_client.send_ticket_to_zendesk(ticket)
|
||||
|
||||
@@ -8,106 +8,102 @@ from app.config import QueueNames
|
||||
from app.utils import DATETIME_FORMAT
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="send-delivery-status", max_retries=5, default_retry_delay=300
|
||||
)
|
||||
def send_delivery_status_to_service(self, notification_id, encrypted_status_update):
|
||||
@notify_celery.task(bind=True, name="send-delivery-status", max_retries=5, default_retry_delay=300)
|
||||
def send_delivery_status_to_service(
|
||||
self, notification_id, encrypted_status_update
|
||||
):
|
||||
status_update = encryption.decrypt(encrypted_status_update)
|
||||
|
||||
data = {
|
||||
"id": str(notification_id),
|
||||
"reference": status_update["notification_client_reference"],
|
||||
"to": status_update["notification_to"],
|
||||
"status": status_update["notification_status"],
|
||||
"created_at": status_update["notification_created_at"],
|
||||
"completed_at": status_update["notification_updated_at"],
|
||||
"sent_at": status_update["notification_sent_at"],
|
||||
"notification_type": status_update["notification_type"],
|
||||
"template_id": status_update["template_id"],
|
||||
"template_version": status_update["template_version"],
|
||||
"reference": status_update['notification_client_reference'],
|
||||
"to": status_update['notification_to'],
|
||||
"status": status_update['notification_status'],
|
||||
"created_at": status_update['notification_created_at'],
|
||||
"completed_at": status_update['notification_updated_at'],
|
||||
"sent_at": status_update['notification_sent_at'],
|
||||
"notification_type": status_update['notification_type'],
|
||||
"template_id": status_update['template_id'],
|
||||
"template_version": status_update['template_version']
|
||||
}
|
||||
|
||||
_send_data_to_service_callback_api(
|
||||
self,
|
||||
data,
|
||||
status_update["service_callback_api_url"],
|
||||
status_update["service_callback_api_bearer_token"],
|
||||
"send_delivery_status_to_service",
|
||||
status_update['service_callback_api_url'],
|
||||
status_update['service_callback_api_bearer_token'],
|
||||
'send_delivery_status_to_service'
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="send-complaint", max_retries=5, default_retry_delay=300
|
||||
)
|
||||
@notify_celery.task(bind=True, name="send-complaint", max_retries=5, default_retry_delay=300)
|
||||
def send_complaint_to_service(self, complaint_data):
|
||||
complaint = encryption.decrypt(complaint_data)
|
||||
|
||||
data = {
|
||||
"notification_id": complaint["notification_id"],
|
||||
"complaint_id": complaint["complaint_id"],
|
||||
"reference": complaint["reference"],
|
||||
"to": complaint["to"],
|
||||
"complaint_date": complaint["complaint_date"],
|
||||
"notification_id": complaint['notification_id'],
|
||||
"complaint_id": complaint['complaint_id'],
|
||||
"reference": complaint['reference'],
|
||||
"to": complaint['to'],
|
||||
"complaint_date": complaint['complaint_date']
|
||||
}
|
||||
|
||||
_send_data_to_service_callback_api(
|
||||
self,
|
||||
data,
|
||||
complaint["service_callback_api_url"],
|
||||
complaint["service_callback_api_bearer_token"],
|
||||
"send_complaint_to_service",
|
||||
complaint['service_callback_api_url'],
|
||||
complaint['service_callback_api_bearer_token'],
|
||||
'send_complaint_to_service'
|
||||
)
|
||||
|
||||
|
||||
def _send_data_to_service_callback_api(
|
||||
self, data, service_callback_url, token, function_name
|
||||
):
|
||||
notification_id = (
|
||||
data["notification_id"] if "notification_id" in data else data["id"]
|
||||
)
|
||||
def _send_data_to_service_callback_api(self, data, service_callback_url, token, function_name):
|
||||
notification_id = (data["notification_id"] if "notification_id" in data else data["id"])
|
||||
try:
|
||||
response = request(
|
||||
method="POST",
|
||||
url=service_callback_url,
|
||||
data=json.dumps(data),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {}".format(token),
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer {}'.format(token)
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
current_app.logger.info(
|
||||
"{} sending {} to {}, response {}".format(
|
||||
function_name,
|
||||
notification_id,
|
||||
service_callback_url,
|
||||
response.status_code,
|
||||
)
|
||||
timeout=5
|
||||
)
|
||||
current_app.logger.info('{} sending {} to {}, response {}'.format(
|
||||
function_name,
|
||||
notification_id,
|
||||
service_callback_url,
|
||||
response.status_code
|
||||
))
|
||||
response.raise_for_status()
|
||||
except RequestException as e:
|
||||
current_app.logger.warning(
|
||||
"{} request failed for notification_id: {} and url: {}. exception: {}".format(
|
||||
function_name, notification_id, service_callback_url, e
|
||||
function_name,
|
||||
notification_id,
|
||||
service_callback_url,
|
||||
e
|
||||
)
|
||||
)
|
||||
if (
|
||||
not isinstance(e, HTTPError)
|
||||
or e.response.status_code >= 500
|
||||
or e.response.status_code == 429
|
||||
):
|
||||
if not isinstance(e, HTTPError) or e.response.status_code >= 500 or e.response.status_code == 429:
|
||||
try:
|
||||
self.retry(queue=QueueNames.CALLBACKS_RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
current_app.logger.warning(
|
||||
"Retry: {} has retried the max num of times for callback url {} and notification_id: {}".format(
|
||||
function_name, service_callback_url, notification_id
|
||||
function_name,
|
||||
service_callback_url,
|
||||
notification_id
|
||||
)
|
||||
)
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
"{} callback is not being retried for notification_id: {} and url: {}. exception: {}".format(
|
||||
function_name, notification_id, service_callback_url, e
|
||||
function_name,
|
||||
notification_id,
|
||||
service_callback_url,
|
||||
e
|
||||
)
|
||||
)
|
||||
|
||||
@@ -120,16 +116,9 @@ def create_delivery_status_callback_data(notification, service_callback_api):
|
||||
"notification_status": notification.status,
|
||||
"notification_provider_response": notification.provider_response, # TODO do we test for provider_response?
|
||||
"notification_created_at": notification.created_at.strftime(DATETIME_FORMAT),
|
||||
"notification_updated_at": (
|
||||
notification.updated_at.strftime(DATETIME_FORMAT)
|
||||
if notification.updated_at
|
||||
else None
|
||||
),
|
||||
"notification_sent_at": (
|
||||
notification.sent_at.strftime(DATETIME_FORMAT)
|
||||
if notification.sent_at
|
||||
else None
|
||||
),
|
||||
"notification_updated_at":
|
||||
notification.updated_at.strftime(DATETIME_FORMAT) if notification.updated_at else None,
|
||||
"notification_sent_at": notification.sent_at.strftime(DATETIME_FORMAT) if notification.sent_at else None,
|
||||
"notification_type": notification.notification_type,
|
||||
"service_callback_api_url": service_callback_api.url,
|
||||
"service_callback_api_bearer_token": service_callback_api.bearer_token,
|
||||
@@ -139,9 +128,7 @@ def create_delivery_status_callback_data(notification, service_callback_api):
|
||||
return encryption.encrypt(data)
|
||||
|
||||
|
||||
def create_complaint_callback_data(
|
||||
complaint, notification, service_callback_api, recipient
|
||||
):
|
||||
def create_complaint_callback_data(complaint, notification, service_callback_api, recipient):
|
||||
data = {
|
||||
"complaint_id": str(complaint.id),
|
||||
"notification_id": str(notification.id),
|
||||
|
||||
@@ -20,67 +20,60 @@ from app.dao.service_email_reply_to_dao import dao_get_reply_to_by_id
|
||||
from app.dao.service_inbound_api_dao import get_service_inbound_api_for_service
|
||||
from app.dao.service_sms_sender_dao import dao_get_service_sms_senders_by_id
|
||||
from app.dao.templates_dao import dao_get_template_by_id
|
||||
from app.enums import JobStatus, KeyType, NotificationType
|
||||
from app.models import (
|
||||
EMAIL_TYPE,
|
||||
JOB_STATUS_CANCELLED,
|
||||
JOB_STATUS_FINISHED,
|
||||
JOB_STATUS_IN_PROGRESS,
|
||||
JOB_STATUS_PENDING,
|
||||
KEY_TYPE_NORMAL,
|
||||
SMS_TYPE,
|
||||
)
|
||||
from app.notifications.process_notifications import persist_notification
|
||||
from app.notifications.validators import check_service_over_total_message_limit
|
||||
from app.notifications.validators import check_service_over_daily_message_limit
|
||||
from app.serialised_models import SerialisedService, SerialisedTemplate
|
||||
from app.service.utils import service_allowed_to_send_to
|
||||
from app.utils import DATETIME_FORMAT
|
||||
from app.v2.errors import TotalRequestsError
|
||||
from app.v2.errors import TooManyRequestsError
|
||||
|
||||
|
||||
@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(
|
||||
"Starting process-job task for job id {} with status: {}".format(
|
||||
job_id, job.job_status
|
||||
)
|
||||
)
|
||||
current_app.logger.info("Starting process-job task for job id {} with status: {}".format(job_id, job.job_status))
|
||||
|
||||
if job.job_status != JobStatus.PENDING:
|
||||
if job.job_status != JOB_STATUS_PENDING:
|
||||
return
|
||||
|
||||
service = job.service
|
||||
|
||||
job.job_status = JobStatus.IN_PROGRESS
|
||||
job.job_status = JOB_STATUS_IN_PROGRESS
|
||||
job.processing_started = start
|
||||
dao_update_job(job)
|
||||
|
||||
if not service.active:
|
||||
job.job_status = JobStatus.CANCELLED
|
||||
job.job_status = JOB_STATUS_CANCELLED
|
||||
dao_update_job(job)
|
||||
current_app.logger.warning(
|
||||
"Job {} has been cancelled, service {} is inactive".format(
|
||||
job_id, service.id
|
||||
)
|
||||
)
|
||||
"Job {} has been cancelled, service {} is inactive".format(job_id, service.id))
|
||||
return
|
||||
|
||||
if __total_sending_limits_for_job_exceeded(service, job, job_id):
|
||||
if __sending_limits_for_job_exceeded(service, job, job_id):
|
||||
return
|
||||
|
||||
recipient_csv, template, sender_id = get_recipient_csv_and_template_and_sender_id(
|
||||
job
|
||||
)
|
||||
recipient_csv, template, sender_id = get_recipient_csv_and_template_and_sender_id(job)
|
||||
|
||||
current_app.logger.info(
|
||||
"Starting job {} processing {} notifications".format(
|
||||
job_id, job.notification_count
|
||||
)
|
||||
)
|
||||
current_app.logger.info("Starting job {} processing {} notifications".format(job_id, job.notification_count))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def job_complete(job, resumed=False, start=None):
|
||||
job.job_status = JobStatus.FINISHED
|
||||
job.job_status = JOB_STATUS_FINISHED
|
||||
|
||||
finished = datetime.utcnow()
|
||||
job.processing_finished = finished
|
||||
@@ -92,9 +85,7 @@ def job_complete(job, resumed=False, start=None):
|
||||
)
|
||||
else:
|
||||
current_app.logger.info(
|
||||
"Job {} created at {} started at {} finished at {}".format(
|
||||
job.id, job.created_at, start, finished
|
||||
)
|
||||
"Job {} created at {} started at {} finished at {}".format(job.id, job.created_at, start, finished)
|
||||
)
|
||||
|
||||
|
||||
@@ -102,40 +93,35 @@ def get_recipient_csv_and_template_and_sender_id(job):
|
||||
db_template = dao_get_template_by_id(job.template_id, job.template_version)
|
||||
template = db_template._as_utils_template()
|
||||
|
||||
contents, meta_data = s3.get_job_and_metadata_from_s3(
|
||||
service_id=str(job.service_id), job_id=str(job.id)
|
||||
)
|
||||
contents, meta_data = s3.get_job_and_metadata_from_s3(service_id=str(job.service_id), job_id=str(job.id))
|
||||
recipient_csv = RecipientCSV(contents, template=template)
|
||||
|
||||
return recipient_csv, template, meta_data.get("sender_id")
|
||||
|
||||
|
||||
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(
|
||||
{
|
||||
"template": str(template.id),
|
||||
"template_version": job.template_version,
|
||||
"job": str(job.id),
|
||||
"to": row.recipient,
|
||||
"row_number": row.index,
|
||||
"personalisation": dict(row.personalisation),
|
||||
}
|
||||
)
|
||||
encrypted = encryption.encrypt({
|
||||
'template': str(template.id),
|
||||
'template_version': job.template_version,
|
||||
'job': str(job.id),
|
||||
'to': row.recipient,
|
||||
'row_number': row.index,
|
||||
'personalisation': dict(row.personalisation)
|
||||
})
|
||||
|
||||
# Both save_sms and save_email have the same general
|
||||
# persist logic.
|
||||
send_fns = {NotificationType.SMS: save_sms, NotificationType.EMAIL: save_email}
|
||||
send_fns = {
|
||||
SMS_TYPE: save_sms,
|
||||
EMAIL_TYPE: save_email
|
||||
}
|
||||
|
||||
send_fn = send_fns[template_type]
|
||||
|
||||
task_kwargs = {}
|
||||
if sender_id:
|
||||
task_kwargs["sender_id"] = sender_id
|
||||
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),
|
||||
@@ -143,112 +129,100 @@ def process_row(row, template, job, service, sender_id=None):
|
||||
encrypted,
|
||||
),
|
||||
task_kwargs,
|
||||
queue=QueueNames.DATABASE,
|
||||
queue=QueueNames.DATABASE if not service.research_mode else QueueNames.RESEARCH_MODE
|
||||
)
|
||||
return notification_id
|
||||
|
||||
|
||||
def __total_sending_limits_for_job_exceeded(service, job, job_id):
|
||||
def __sending_limits_for_job_exceeded(service, job, job_id):
|
||||
try:
|
||||
total_sent = check_service_over_total_message_limit(KeyType.NORMAL, service)
|
||||
if total_sent + job.notification_count > service.total_message_limit:
|
||||
raise TotalRequestsError(service.total_message_limit)
|
||||
total_sent = check_service_over_daily_message_limit(KEY_TYPE_NORMAL, service)
|
||||
if total_sent + job.notification_count > service.message_limit:
|
||||
raise TooManyRequestsError(service.message_limit)
|
||||
else:
|
||||
return False
|
||||
except TotalRequestsError:
|
||||
job.job_status = "sending limits exceeded"
|
||||
except TooManyRequestsError:
|
||||
job.job_status = 'sending limits exceeded'
|
||||
job.processing_finished = datetime.utcnow()
|
||||
dao_update_job(job)
|
||||
current_app.logger.error(
|
||||
"Job {} size {} error. Total sending limits {} exceeded".format(
|
||||
job_id, job.notification_count, service.message_limit
|
||||
)
|
||||
current_app.logger.info(
|
||||
"Job {} size {} error. Sending limits {} exceeded".format(
|
||||
job_id, job.notification_count, service.message_limit)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@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."""
|
||||
def save_sms(self,
|
||||
service_id,
|
||||
notification_id,
|
||||
encrypted_notification,
|
||||
sender_id=None):
|
||||
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"],
|
||||
notification['template'],
|
||||
service_id=service.id,
|
||||
version=notification["template_version"],
|
||||
version=notification['template_version'],
|
||||
)
|
||||
|
||||
if sender_id:
|
||||
reply_to_text = dao_get_service_sms_senders_by_id(
|
||||
service_id, sender_id
|
||||
).sms_sender
|
||||
reply_to_text = dao_get_service_sms_senders_by_id(service_id, sender_id).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):
|
||||
|
||||
if not service_allowed_to_send_to(notification['to'], service, KEY_TYPE_NORMAL):
|
||||
current_app.logger.debug(
|
||||
"SMS {} failed as restricted service".format(notification_id)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
job_id = notification.get("job", None)
|
||||
created_by_id = None
|
||||
if job_id:
|
||||
job = dao_get_job_by_id(job_id)
|
||||
created_by_id = job.created_by_id
|
||||
|
||||
saved_notification = persist_notification(
|
||||
template_id=notification["template"],
|
||||
template_version=notification["template_version"],
|
||||
recipient=notification["to"],
|
||||
template_id=notification['template'],
|
||||
template_version=notification['template_version'],
|
||||
recipient=notification['to'],
|
||||
service=service,
|
||||
personalisation=notification.get("personalisation"),
|
||||
notification_type=NotificationType.SMS,
|
||||
personalisation=notification.get('personalisation'),
|
||||
notification_type=SMS_TYPE,
|
||||
api_key_id=None,
|
||||
key_type=KeyType.NORMAL,
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
created_at=datetime.utcnow(),
|
||||
created_by_id=created_by_id,
|
||||
job_id=notification.get("job", None),
|
||||
job_row_number=notification.get("row_number", None),
|
||||
job_id=notification.get('job', None),
|
||||
job_row_number=notification.get('row_number', None),
|
||||
notification_id=notification_id,
|
||||
reply_to_text=reply_to_text,
|
||||
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
|
||||
[str(saved_notification.id)],
|
||||
queue=QueueNames.SEND_SMS if not service.research_mode else QueueNames.RESEARCH_MODE
|
||||
)
|
||||
|
||||
current_app.logger.debug(
|
||||
"SMS {} created at {} for job {}".format(
|
||||
saved_notification.id,
|
||||
saved_notification.created_at,
|
||||
notification.get("job", None),
|
||||
)
|
||||
notification.get('job', None))
|
||||
)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
handle_exception(self, notification, notification_id, e)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="save-email", max_retries=5, default_retry_delay=300
|
||||
)
|
||||
def save_email(
|
||||
self, service_id, notification_id, encrypted_notification, sender_id=None
|
||||
):
|
||||
@notify_celery.task(bind=True, name="save-email", max_retries=5, default_retry_delay=300)
|
||||
def save_email(self,
|
||||
service_id,
|
||||
notification_id,
|
||||
encrypted_notification,
|
||||
sender_id=None):
|
||||
notification = encryption.decrypt(encrypted_notification)
|
||||
|
||||
service = SerialisedService.from_id(service_id)
|
||||
template = SerialisedTemplate.from_id_and_service_id(
|
||||
notification["template"],
|
||||
notification['template'],
|
||||
service_id=service.id,
|
||||
version=notification["template_version"],
|
||||
version=notification['template_version'],
|
||||
)
|
||||
|
||||
if sender_id:
|
||||
@@ -256,143 +230,127 @@ def save_email(
|
||||
else:
|
||||
reply_to_text = template.reply_to_text
|
||||
|
||||
if not service_allowed_to_send_to(notification["to"], service, KeyType.NORMAL):
|
||||
current_app.logger.info(
|
||||
"Email {} failed as restricted service".format(notification_id)
|
||||
)
|
||||
if not service_allowed_to_send_to(notification['to'], service, KEY_TYPE_NORMAL):
|
||||
current_app.logger.info("Email {} failed as restricted service".format(notification_id))
|
||||
return
|
||||
|
||||
try:
|
||||
saved_notification = persist_notification(
|
||||
template_id=notification["template"],
|
||||
template_version=notification["template_version"],
|
||||
recipient=notification["to"],
|
||||
template_id=notification['template'],
|
||||
template_version=notification['template_version'],
|
||||
recipient=notification['to'],
|
||||
service=service,
|
||||
personalisation=notification.get("personalisation"),
|
||||
notification_type=NotificationType.EMAIL,
|
||||
personalisation=notification.get('personalisation'),
|
||||
notification_type=EMAIL_TYPE,
|
||||
api_key_id=None,
|
||||
key_type=KeyType.NORMAL,
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
created_at=datetime.utcnow(),
|
||||
job_id=notification.get("job", None),
|
||||
job_row_number=notification.get("row_number", None),
|
||||
job_id=notification.get('job', None),
|
||||
job_row_number=notification.get('row_number', None),
|
||||
notification_id=notification_id,
|
||||
reply_to_text=reply_to_text,
|
||||
reply_to_text=reply_to_text
|
||||
)
|
||||
|
||||
provider_tasks.deliver_email.apply_async(
|
||||
[str(saved_notification.id)], queue=QueueNames.SEND_EMAIL
|
||||
[str(saved_notification.id)],
|
||||
queue=QueueNames.SEND_EMAIL if not service.research_mode else QueueNames.RESEARCH_MODE
|
||||
)
|
||||
|
||||
current_app.logger.debug(
|
||||
"Email {} created at {}".format(
|
||||
saved_notification.id, saved_notification.created_at
|
||||
)
|
||||
)
|
||||
current_app.logger.debug("Email {} created at {}".format(saved_notification.id, saved_notification.created_at))
|
||||
except SQLAlchemyError as e:
|
||||
handle_exception(self, notification, notification_id, e)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="save-api-email", max_retries=5, default_retry_delay=300
|
||||
)
|
||||
@notify_celery.task(bind=True, name="save-api-email", max_retries=5, default_retry_delay=300)
|
||||
def save_api_email(self, encrypted_notification):
|
||||
|
||||
save_api_email_or_sms(self, encrypted_notification)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="save-api-sms", max_retries=5, default_retry_delay=300
|
||||
)
|
||||
@notify_celery.task(bind=True, name="save-api-sms", max_retries=5, default_retry_delay=300)
|
||||
def save_api_sms(self, encrypted_notification):
|
||||
save_api_email_or_sms(self, encrypted_notification)
|
||||
|
||||
|
||||
def save_api_email_or_sms(self, encrypted_notification):
|
||||
notification = encryption.decrypt(encrypted_notification)
|
||||
service = SerialisedService.from_id(notification["service_id"])
|
||||
q = (
|
||||
QueueNames.SEND_EMAIL
|
||||
if notification["notification_type"] == NotificationType.EMAIL
|
||||
else QueueNames.SEND_SMS
|
||||
)
|
||||
provider_task = (
|
||||
provider_tasks.deliver_email
|
||||
if notification["notification_type"] == NotificationType.EMAIL
|
||||
service = SerialisedService.from_id(notification['service_id'])
|
||||
q = QueueNames.SEND_EMAIL if notification['notification_type'] == EMAIL_TYPE else QueueNames.SEND_SMS
|
||||
provider_task = provider_tasks.deliver_email if notification['notification_type'] == EMAIL_TYPE \
|
||||
else provider_tasks.deliver_sms
|
||||
)
|
||||
try:
|
||||
|
||||
persist_notification(
|
||||
notification_id=notification["id"],
|
||||
template_id=notification["template_id"],
|
||||
template_version=notification["template_version"],
|
||||
recipient=notification["to"],
|
||||
template_id=notification['template_id'],
|
||||
template_version=notification['template_version'],
|
||||
recipient=notification['to'],
|
||||
service=service,
|
||||
personalisation=notification.get("personalisation"),
|
||||
notification_type=notification["notification_type"],
|
||||
client_reference=notification["client_reference"],
|
||||
api_key_id=notification.get("api_key_id"),
|
||||
key_type=KeyType.NORMAL,
|
||||
created_at=notification["created_at"],
|
||||
reply_to_text=notification["reply_to_text"],
|
||||
status=notification["status"],
|
||||
document_download_count=notification["document_download_count"],
|
||||
personalisation=notification.get('personalisation'),
|
||||
notification_type=notification['notification_type'],
|
||||
client_reference=notification['client_reference'],
|
||||
api_key_id=notification.get('api_key_id'),
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
created_at=notification['created_at'],
|
||||
reply_to_text=notification['reply_to_text'],
|
||||
status=notification['status'],
|
||||
document_download_count=notification['document_download_count']
|
||||
)
|
||||
|
||||
provider_task.apply_async([notification["id"]], queue=q)
|
||||
q = q if not service.research_mode else QueueNames.RESEARCH_MODE
|
||||
provider_task.apply_async(
|
||||
[notification['id']],
|
||||
queue=q
|
||||
)
|
||||
current_app.logger.debug(
|
||||
f"{notification['notification_type']} {notification['id']} has been persisted and sent to delivery queue."
|
||||
)
|
||||
except IntegrityError:
|
||||
current_app.logger.info(
|
||||
f"{notification['notification_type']} {notification['id']} already exists."
|
||||
)
|
||||
current_app.logger.info(f"{notification['notification_type']} {notification['id']} already exists.")
|
||||
|
||||
except SQLAlchemyError:
|
||||
|
||||
try:
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
current_app.logger.error(
|
||||
f"Max retry failed Failed to persist notification {notification['id']}"
|
||||
)
|
||||
current_app.logger.error(f"Max retry failed Failed to persist notification {notification['id']}")
|
||||
|
||||
|
||||
def handle_exception(task, notification, notification_id, exc):
|
||||
if not get_notification_by_id(notification_id):
|
||||
retry_msg = "{task} notification for job {job} row number {row} and notification id {noti}".format(
|
||||
retry_msg = '{task} notification for job {job} row number {row} and notification id {noti}'.format(
|
||||
task=task.__name__,
|
||||
job=notification.get("job", None),
|
||||
row=notification.get("row_number", None),
|
||||
noti=notification_id,
|
||||
job=notification.get('job', None),
|
||||
row=notification.get('row_number', None),
|
||||
noti=notification_id
|
||||
)
|
||||
# Sometimes, SQS plays the same message twice. We should be able to catch an IntegrityError, but it seems
|
||||
# SQLAlchemy is throwing a FlushError. So we check if the notification id already exists then do not
|
||||
# send to the retry queue.
|
||||
# This probably (hopefully) is not an issue with Redis as the celery backing store
|
||||
current_app.logger.exception("Retry" + retry_msg)
|
||||
current_app.logger.exception('Retry' + retry_msg)
|
||||
try:
|
||||
task.retry(queue=QueueNames.RETRY, exc=exc)
|
||||
except task.MaxRetriesExceededError:
|
||||
current_app.logger.error("Max retry failed" + retry_msg)
|
||||
current_app.logger.error('Max retry failed' + retry_msg)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="send-inbound-sms", max_retries=5, default_retry_delay=300
|
||||
)
|
||||
@notify_celery.task(bind=True, name="send-inbound-sms", max_retries=5, default_retry_delay=300)
|
||||
def send_inbound_sms_to_service(self, inbound_sms_id, service_id):
|
||||
inbound_api = get_service_inbound_api_for_service(service_id=service_id)
|
||||
if not inbound_api:
|
||||
# No API data has been set for this service
|
||||
return
|
||||
|
||||
inbound_sms = dao_get_inbound_sms_by_id(
|
||||
service_id=service_id, inbound_id=inbound_sms_id
|
||||
)
|
||||
inbound_sms = dao_get_inbound_sms_by_id(service_id=service_id,
|
||||
inbound_id=inbound_sms_id)
|
||||
data = {
|
||||
"id": str(inbound_sms.id),
|
||||
# TODO: should we be validating and formatting the phone number here?
|
||||
"source_number": inbound_sms.user_number,
|
||||
"destination_number": inbound_sms.notify_number,
|
||||
"message": inbound_sms.content,
|
||||
"date_received": inbound_sms.provider_date.strftime(DATETIME_FORMAT),
|
||||
"date_received": inbound_sms.provider_date.strftime(DATETIME_FORMAT)
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -401,43 +359,43 @@ def send_inbound_sms_to_service(self, inbound_sms_id, service_id):
|
||||
url=inbound_api.url,
|
||||
data=json.dumps(data),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {}".format(inbound_api.bearer_token),
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer {}'.format(inbound_api.bearer_token)
|
||||
},
|
||||
timeout=60,
|
||||
timeout=60
|
||||
)
|
||||
current_app.logger.debug(
|
||||
f"send_inbound_sms_to_service sending {inbound_sms_id} to {inbound_api.url}, "
|
||||
+ f"response {response.status_code}"
|
||||
f"send_inbound_sms_to_service sending {inbound_sms_id} to {inbound_api.url}, " +
|
||||
f"response {response.status_code}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as e:
|
||||
current_app.logger.warning(
|
||||
f"send_inbound_sms_to_service failed for service_id: {service_id} for inbound_sms_id: {inbound_sms_id} "
|
||||
+ f"and url: {inbound_api.url}. exception: {e}"
|
||||
f"send_inbound_sms_to_service failed for service_id: {service_id} for inbound_sms_id: {inbound_sms_id} " +
|
||||
f"and url: {inbound_api.url}. exception: {e}"
|
||||
)
|
||||
if not isinstance(e, HTTPError) or e.response.status_code >= 500:
|
||||
try:
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
current_app.logger.error(
|
||||
"Retry: send_inbound_sms_to_service has retried the max number of"
|
||||
+ f"times for service: {service_id} and inbound_sms {inbound_sms_id}"
|
||||
"Retry: send_inbound_sms_to_service has retried the max number of" +
|
||||
f"times for service: {service_id} and inbound_sms {inbound_sms_id}"
|
||||
)
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
f"send_inbound_sms_to_service is not being retried for service_id: {service_id} for "
|
||||
+ f"inbound_sms id: {inbound_sms_id} and url: {inbound_api.url}. exception: {e}"
|
||||
f"send_inbound_sms_to_service is not being retried for service_id: {service_id} for " +
|
||||
f"inbound_sms id: {inbound_sms_id} and url: {inbound_api.url}. exception: {e}"
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name="process-incomplete-jobs")
|
||||
@notify_celery.task(name='process-incomplete-jobs')
|
||||
def process_incomplete_jobs(job_ids):
|
||||
jobs = [dao_get_job_by_id(job_id) for job_id in job_ids]
|
||||
|
||||
# reset the processing start time so that the check_job_status scheduled task doesn't pick this job up again
|
||||
for job in jobs:
|
||||
job.job_status = JobStatus.IN_PROGRESS
|
||||
job.job_status = JOB_STATUS_IN_PROGRESS
|
||||
job.processing_started = datetime.utcnow()
|
||||
dao_update_job(job)
|
||||
|
||||
@@ -456,13 +414,9 @@ def process_incomplete_job(job_id):
|
||||
else:
|
||||
resume_from_row = -1 # The first row in the csv with a number is row 0
|
||||
|
||||
current_app.logger.info(
|
||||
"Resuming job {} from row {}".format(job_id, resume_from_row)
|
||||
)
|
||||
current_app.logger.info("Resuming job {} from row {}".format(job_id, resume_from_row))
|
||||
|
||||
recipient_csv, template, sender_id = get_recipient_csv_and_template_and_sender_id(
|
||||
job
|
||||
)
|
||||
recipient_csv, template, sender_id = get_recipient_csv_and_template_and_sender_id(job)
|
||||
|
||||
for row in recipient_csv.get_rows():
|
||||
if row.index > resume_from_row:
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import json
|
||||
|
||||
from flask import current_app
|
||||
from requests import HTTPError, request
|
||||
|
||||
from app.celery.process_ses_receipts_tasks import process_ses_results
|
||||
from app.config import QueueNames
|
||||
from app.dao.notifications_dao import get_notification_by_id
|
||||
from app.enums import NotificationType
|
||||
|
||||
temp_fail = "2028675303"
|
||||
perm_fail = "2028675302"
|
||||
delivered = "2028675309"
|
||||
|
||||
delivered_email = "delivered@simulator.notify"
|
||||
perm_fail_email = "perm-fail@simulator.notify"
|
||||
temp_fail_email = "temp-fail@simulator.notify"
|
||||
|
||||
|
||||
def send_sms_response(provider, reference):
|
||||
body = sns_callback(reference)
|
||||
headers = {"Content-type": "application/json"}
|
||||
|
||||
make_request(NotificationType.SMS, provider, body, headers)
|
||||
|
||||
|
||||
def send_email_response(reference, to):
|
||||
if to == perm_fail_email:
|
||||
body = ses_hard_bounce_callback(reference)
|
||||
elif to == temp_fail_email:
|
||||
body = ses_soft_bounce_callback(reference)
|
||||
else:
|
||||
body = ses_notification_callback(reference)
|
||||
|
||||
process_ses_results.apply_async([body], queue=QueueNames.SEND_EMAIL)
|
||||
|
||||
|
||||
def make_request(notification_type, provider, data, headers):
|
||||
api_call = "{}/notifications/{}/{}".format(
|
||||
current_app.config["API_HOST_NAME"], notification_type, provider
|
||||
)
|
||||
|
||||
try:
|
||||
response = request("POST", api_call, headers=headers, data=data, timeout=60)
|
||||
response.raise_for_status()
|
||||
except HTTPError as e:
|
||||
current_app.logger.error(
|
||||
"API POST request on {} failed with status {}".format(
|
||||
api_call, e.response.status_code
|
||||
)
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
current_app.logger.info("Mocked provider callback request finished")
|
||||
return response.json()
|
||||
|
||||
|
||||
def sns_callback(notification_id):
|
||||
notification = get_notification_by_id(notification_id)
|
||||
|
||||
# This will only work if all notifications, including successful ones, are in the notifications table
|
||||
# If we decide to delete successful notifications, we will have to get this from notifications history
|
||||
return json.dumps(
|
||||
{
|
||||
"CID": str(notification_id),
|
||||
"status": notification.status,
|
||||
# "deliverytime": notification.completed_at
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def ses_notification_callback(reference):
|
||||
ses_message_body = {
|
||||
"delivery": {
|
||||
"processingTimeMillis": 2003,
|
||||
"recipients": ["success@simulator.amazonses.com"],
|
||||
"remoteMtaIp": "123.123.123.123",
|
||||
"reportingMTA": "a7-32.smtp-out.us-west-2.amazonses.com",
|
||||
"smtpResponse": "250 2.6.0 Message received",
|
||||
"timestamp": "2017-11-17T12:14:03.646Z",
|
||||
},
|
||||
"mail": {
|
||||
"commonHeaders": {
|
||||
"from": ["TEST <TEST@notify.works>"],
|
||||
"subject": "lambda test",
|
||||
"to": ["success@simulator.amazonses.com"],
|
||||
},
|
||||
"destination": ["success@simulator.amazonses.com"],
|
||||
"headers": [
|
||||
{"name": "From", "value": "TEST <TEST@notify.works>"},
|
||||
{"name": "To", "value": "success@simulator.amazonses.com"},
|
||||
{"name": "Subject", "value": "lambda test"},
|
||||
{"name": "MIME-Version", "value": "1.0"},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": 'multipart/alternative; boundary="----=_Part_617203_1627511946.1510920841645"',
|
||||
},
|
||||
],
|
||||
"headersTruncated": False,
|
||||
"messageId": reference,
|
||||
"sendingAccountId": "12341234",
|
||||
"source": '"TEST" <TEST@notify.works>',
|
||||
"sourceArn": "arn:aws:ses:us-west-2:12341234:identity/notify.works",
|
||||
"sourceIp": "0.0.0.1",
|
||||
"timestamp": "2017-11-17T12:14:01.643Z",
|
||||
},
|
||||
"notificationType": "Delivery",
|
||||
}
|
||||
|
||||
return {
|
||||
"Type": "Notification",
|
||||
"MessageId": "8e83c020-1234-1234-1234-92a8ee9baa0a",
|
||||
"TopicArn": "arn:aws:sns:us-west-2:12341234:ses_notifications",
|
||||
"Subject": None,
|
||||
"Message": json.dumps(ses_message_body),
|
||||
"Timestamp": "2017-11-17T12:14:03.710Z",
|
||||
"SignatureVersion": "1",
|
||||
"Signature": "[REDACTED]",
|
||||
"SigningCertUrl": "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-[REDACTED].pem",
|
||||
"UnsubscribeUrl": "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REACTED]",
|
||||
"MessageAttributes": {},
|
||||
}
|
||||
|
||||
|
||||
def ses_hard_bounce_callback(reference):
|
||||
return _ses_bounce_callback(reference, "Permanent")
|
||||
|
||||
|
||||
def ses_soft_bounce_callback(reference):
|
||||
return _ses_bounce_callback(reference, "Temporary")
|
||||
|
||||
|
||||
def _ses_bounce_callback(reference, bounce_type):
|
||||
ses_message_body = {
|
||||
"bounce": {
|
||||
"bounceSubType": "General",
|
||||
"bounceType": bounce_type,
|
||||
"bouncedRecipients": [
|
||||
{
|
||||
"action": "failed",
|
||||
"diagnosticCode": "smtp; 550 5.1.1 user unknown",
|
||||
"emailAddress": "bounce@simulator.amazonses.com",
|
||||
"status": "5.1.1",
|
||||
}
|
||||
],
|
||||
"feedbackId": "0102015fc9e676fb-12341234-1234-1234-1234-9301e86a4fa8-000000",
|
||||
"remoteMtaIp": "123.123.123.123",
|
||||
"reportingMTA": "dsn; a7-31.smtp-out.us-west-2.amazonses.com",
|
||||
"timestamp": "2017-11-17T12:14:05.131Z",
|
||||
},
|
||||
"mail": {
|
||||
"commonHeaders": {
|
||||
"from": ["TEST <TEST@notify.works>"],
|
||||
"subject": "ses callback test",
|
||||
"to": ["bounce@simulator.amazonses.com"],
|
||||
},
|
||||
"destination": ["bounce@simulator.amazonses.com"],
|
||||
"headers": [
|
||||
{"name": "From", "value": "TEST <TEST@notify.works>"},
|
||||
{"name": "To", "value": "bounce@simulator.amazonses.com"},
|
||||
{"name": "Subject", "value": "lambda test"},
|
||||
{"name": "MIME-Version", "value": "1.0"},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": 'multipart/alternative; boundary="----=_Part_596529_2039165601.1510920843367"',
|
||||
},
|
||||
],
|
||||
"headersTruncated": False,
|
||||
"messageId": reference,
|
||||
"sendingAccountId": "12341234",
|
||||
"source": '"TEST" <TEST@notify.works>',
|
||||
"sourceArn": "arn:aws:ses:us-west-2:12341234:identity/notify.works",
|
||||
"sourceIp": "0.0.0.1",
|
||||
"timestamp": "2017-11-17T12:14:03.000Z",
|
||||
},
|
||||
"notificationType": "Bounce",
|
||||
}
|
||||
return {
|
||||
"Type": "Notification",
|
||||
"MessageId": "36e67c28-1234-1234-1234-2ea0172aa4a7",
|
||||
"TopicArn": "arn:aws:sns:us-west-2:12341234:ses_notifications",
|
||||
"Subject": None,
|
||||
"Message": json.dumps(ses_message_body),
|
||||
"Timestamp": "2017-11-17T12:14:05.149Z",
|
||||
"SignatureVersion": "1",
|
||||
"Signature": "[REDACTED]", # noqa
|
||||
"SigningCertUrl": "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-[REDACTED]].pem",
|
||||
"UnsubscribeUrl": "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REDACTED]]",
|
||||
"MessageAttributes": {},
|
||||
}
|
||||
@@ -1,37 +1,20 @@
|
||||
from abc import abstractmethod
|
||||
from typing import Protocol
|
||||
|
||||
from botocore.config import Config
|
||||
|
||||
from app.enums import NotificationType
|
||||
|
||||
AWS_CLIENT_CONFIG = Config(
|
||||
# This config is required to enable S3 to connect to FIPS-enabled
|
||||
# endpoints. See https://aws.amazon.com/compliance/fips/ for more
|
||||
# information.
|
||||
s3={
|
||||
"addressing_style": "virtual",
|
||||
},
|
||||
use_fips_endpoint=True,
|
||||
)
|
||||
|
||||
|
||||
class ClientException(Exception):
|
||||
"""
|
||||
'''
|
||||
Base Exceptions for sending notifications that fail
|
||||
"""
|
||||
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class Client(Protocol):
|
||||
"""
|
||||
class Client(object):
|
||||
'''
|
||||
Base client for sending notifications.
|
||||
"""
|
||||
'''
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def init_app(self, current_app, *args, **kwargs):
|
||||
raise NotImplementedError("TODO: Need to implement.")
|
||||
|
||||
STATISTICS_REQUESTED = 'requested'
|
||||
STATISTICS_DELIVERED = 'delivered'
|
||||
STATISTICS_FAILURE = 'failure'
|
||||
|
||||
|
||||
class NotificationProviderClients(object):
|
||||
@@ -52,13 +35,10 @@ class NotificationProviderClients(object):
|
||||
return self.email_clients.get(name)
|
||||
|
||||
def get_client_by_name_and_type(self, name, notification_type):
|
||||
assert notification_type in {
|
||||
NotificationType.EMAIL,
|
||||
NotificationType.SMS,
|
||||
} # nosec B101
|
||||
assert notification_type in ['email', 'sms'] # nosec B101
|
||||
|
||||
if notification_type == NotificationType.EMAIL:
|
||||
if notification_type == 'email':
|
||||
return self.get_email_client(name)
|
||||
|
||||
if notification_type == NotificationType.SMS:
|
||||
if notification_type == 'sms':
|
||||
return self.get_sms_client(name)
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from boto3 import client
|
||||
from flask import current_app
|
||||
|
||||
from app.clients import AWS_CLIENT_CONFIG, Client
|
||||
from app.cloudfoundry_config import cloud_config
|
||||
from app.exceptions import NotificationTechnicalFailureException
|
||||
|
||||
|
||||
class AwsCloudwatchClient(Client):
|
||||
"""
|
||||
This client is responsible for retrieving sms delivery receipts from cloudwatch.
|
||||
"""
|
||||
|
||||
def init_app(self, current_app, *args, **kwargs):
|
||||
if os.getenv("LOCALSTACK_ENDPOINT_URL"):
|
||||
self._client = client(
|
||||
"logs",
|
||||
region_name=cloud_config.sns_region,
|
||||
aws_access_key_id=cloud_config.sns_access_key,
|
||||
aws_secret_access_key=cloud_config.sns_secret_key,
|
||||
config=AWS_CLIENT_CONFIG,
|
||||
endpoint_url=os.getenv("LOCALSTACK_ENDPOINT_URL"),
|
||||
)
|
||||
self._is_localstack = True
|
||||
else:
|
||||
self._client = client(
|
||||
"logs",
|
||||
region_name=cloud_config.sns_region,
|
||||
aws_access_key_id=cloud_config.sns_access_key,
|
||||
aws_secret_access_key=cloud_config.sns_secret_key,
|
||||
config=AWS_CLIENT_CONFIG,
|
||||
)
|
||||
self._is_localstack = False
|
||||
|
||||
super(Client, self).__init__(*args, **kwargs)
|
||||
self.current_app = current_app
|
||||
self._valid_sender_regex = re.compile(r"^\+?\d{5,14}$")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "cloudwatch"
|
||||
|
||||
def is_localstack(self):
|
||||
return self._is_localstack
|
||||
|
||||
def _get_log(self, my_filter, log_group_name, sent_at):
|
||||
# Check all cloudwatch logs from the time the notification was sent (currently 5 minutes previously) until now
|
||||
now = datetime.utcnow()
|
||||
beginning = sent_at
|
||||
next_token = None
|
||||
all_log_events = []
|
||||
current_app.logger.info(f"START TIME {beginning} END TIME {now}")
|
||||
# There has been a change somewhere and the time range we were previously using has become too
|
||||
# narrow or wrong in some way, so events can't be found. For the time being, adjust by adding
|
||||
# a buffer on each side of 12 hours.
|
||||
TWELVE_HOURS = 12 * 60 * 60 * 1000
|
||||
while True:
|
||||
if next_token:
|
||||
response = self._client.filter_log_events(
|
||||
logGroupName=log_group_name,
|
||||
filterPattern=my_filter,
|
||||
nextToken=next_token,
|
||||
startTime=int(beginning.timestamp() * 1000) - TWELVE_HOURS,
|
||||
endTime=int(now.timestamp() * 1000) + TWELVE_HOURS,
|
||||
)
|
||||
else:
|
||||
response = self._client.filter_log_events(
|
||||
logGroupName=log_group_name,
|
||||
filterPattern=my_filter,
|
||||
startTime=int(beginning.timestamp() * 1000) - TWELVE_HOURS,
|
||||
endTime=int(now.timestamp() * 1000) + TWELVE_HOURS,
|
||||
)
|
||||
log_events = response.get("events", [])
|
||||
all_log_events.extend(log_events)
|
||||
if len(log_events) > 0:
|
||||
# We found it
|
||||
|
||||
break
|
||||
next_token = response.get("nextToken")
|
||||
if not next_token:
|
||||
break
|
||||
return all_log_events
|
||||
|
||||
def _extract_account_number(self, ses_domain_arn):
|
||||
account_number = ses_domain_arn.split(":")
|
||||
return account_number
|
||||
|
||||
def warn_if_dev_is_opted_out(self, provider_response, notification_id):
|
||||
if (
|
||||
"is opted out" in provider_response.lower()
|
||||
or "has blocked sms" in provider_response.lower()
|
||||
):
|
||||
if os.getenv("NOTIFY_ENVIRONMENT") in ["development", "test"]:
|
||||
ansi_red = "\033[31m"
|
||||
ansi_reset = "\033[0m"
|
||||
logline = (
|
||||
ansi_red
|
||||
+ f"The phone number for notification_id {notification_id} is OPTED OUT. You need to opt back in"
|
||||
+ ansi_reset
|
||||
)
|
||||
current_app.logger.warning(logline)
|
||||
return logline
|
||||
return None
|
||||
|
||||
def check_sms(self, message_id, notification_id, created_at):
|
||||
region = cloud_config.sns_region
|
||||
# TODO this clumsy approach to getting the account number will be fixed as part of notify-api #258
|
||||
account_number = self._extract_account_number(cloud_config.ses_domain_arn)
|
||||
|
||||
time_now = datetime.utcnow()
|
||||
log_group_name = f"sns/{region}/{account_number[4]}/DirectPublishToPhoneNumber"
|
||||
filter_pattern = '{$.notification.messageId="XXXXX"}'
|
||||
filter_pattern = filter_pattern.replace("XXXXX", message_id)
|
||||
all_log_events = self._get_log(filter_pattern, log_group_name, created_at)
|
||||
if all_log_events and len(all_log_events) > 0:
|
||||
event = all_log_events[0]
|
||||
message = json.loads(event["message"])
|
||||
self.warn_if_dev_is_opted_out(
|
||||
message["delivery"]["providerResponse"], notification_id
|
||||
)
|
||||
return (
|
||||
"success",
|
||||
message["delivery"]["providerResponse"],
|
||||
message["delivery"].get("phoneCarrier", "Unknown Carrier"),
|
||||
)
|
||||
|
||||
log_group_name = (
|
||||
f"sns/{region}/{account_number[4]}/DirectPublishToPhoneNumber/Failure"
|
||||
)
|
||||
all_failed_events = self._get_log(filter_pattern, log_group_name, created_at)
|
||||
if all_failed_events and len(all_failed_events) > 0:
|
||||
event = all_failed_events[0]
|
||||
message = json.loads(event["message"])
|
||||
self.warn_if_dev_is_opted_out(
|
||||
message["delivery"]["providerResponse"], notification_id
|
||||
)
|
||||
return (
|
||||
"failure",
|
||||
message["delivery"]["providerResponse"],
|
||||
message["delivery"].get("phoneCarrier", "Unknown Carrier"),
|
||||
)
|
||||
|
||||
if time_now > (created_at + timedelta(hours=3)):
|
||||
# see app/models.py Notification. This message corresponds to "permanent-failure",
|
||||
# but we are copy/pasting here to avoid circular imports.
|
||||
return "failure", "Unable to find carrier response."
|
||||
raise NotificationTechnicalFailureException(
|
||||
f"No event found for message_id {message_id} notification_id {notification_id}"
|
||||
)
|
||||
@@ -9,15 +9,16 @@ class DocumentDownloadError(Exception):
|
||||
|
||||
@classmethod
|
||||
def from_exception(cls, e):
|
||||
message = e.response.json()["error"]
|
||||
message = e.response.json()['error']
|
||||
status_code = e.response.status_code
|
||||
return cls(message, status_code)
|
||||
|
||||
|
||||
class DocumentDownloadClient:
|
||||
|
||||
def init_app(self, app):
|
||||
self.api_host = app.config["DOCUMENT_DOWNLOAD_API_HOST"]
|
||||
self.auth_token = app.config["DOCUMENT_DOWNLOAD_API_KEY"]
|
||||
self.api_host = app.config['DOCUMENT_DOWNLOAD_API_HOST']
|
||||
self.auth_token = app.config['DOCUMENT_DOWNLOAD_API_KEY']
|
||||
|
||||
def get_upload_url(self, service_id):
|
||||
return "{}/services/{}/documents".format(self.api_host, service_id)
|
||||
@@ -27,12 +28,12 @@ class DocumentDownloadClient:
|
||||
response = requests.post(
|
||||
self.get_upload_url(service_id),
|
||||
headers={
|
||||
"Authorization": "Bearer {}".format(self.auth_token),
|
||||
'Authorization': "Bearer {}".format(self.auth_token),
|
||||
},
|
||||
json={
|
||||
"document": file_contents,
|
||||
"is_csv": is_csv or False,
|
||||
},
|
||||
'document': file_contents,
|
||||
'is_csv': is_csv or False,
|
||||
}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
@@ -41,16 +42,14 @@ class DocumentDownloadClient:
|
||||
# we don't want to tell users about that, so anything that isn't a 400 (virus scan failed or file type
|
||||
# unrecognised) should be raised as a 500 internal server error here.
|
||||
if e.response is None:
|
||||
raise Exception(f"Unhandled document download error: {repr(e)}")
|
||||
raise Exception(f'Unhandled document download error: {repr(e)}')
|
||||
elif e.response.status_code == 400:
|
||||
error = DocumentDownloadError.from_exception(e)
|
||||
current_app.logger.info(
|
||||
"Document download request failed with error: {}".format(
|
||||
error.message
|
||||
)
|
||||
'Document download request failed with error: {}'.format(error.message)
|
||||
)
|
||||
raise error
|
||||
else:
|
||||
raise Exception(f"Unhandled document download error: {e.response.text}")
|
||||
raise Exception(f'Unhandled document download error: {e.response.text}')
|
||||
|
||||
return response.json()["document"]["url"]
|
||||
return response.json()['document']['url']
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
from abc import abstractmethod, abstractproperty
|
||||
|
||||
from app.clients import Client, ClientException
|
||||
|
||||
|
||||
class EmailClientException(ClientException):
|
||||
"""
|
||||
'''
|
||||
Base Exception for EmailClients
|
||||
"""
|
||||
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class EmailClientNonRetryableException(ClientException):
|
||||
"""
|
||||
'''
|
||||
Represents an error returned from the email client API with a 4xx response code
|
||||
that should not be retried and should instead be marked as technical failure.
|
||||
An example of this would be an email address that makes it through our
|
||||
validation rules but is rejected by SES. There is no point in retrying this type as
|
||||
it will always fail however many calls to SES. Whereas a throttling error would not
|
||||
use this exception as it may succeed if we retry
|
||||
"""
|
||||
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class EmailClient(Client):
|
||||
"""
|
||||
'''
|
||||
Base Email client for sending emails.
|
||||
"""
|
||||
'''
|
||||
|
||||
@abstractmethod
|
||||
def send_email(self, *args, **kwargs):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
raise NotImplementedError('TODO Need to implement.')
|
||||
|
||||
@abstractproperty
|
||||
@property
|
||||
def name(self):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
raise NotImplementedError('TODO Need to implement.')
|
||||
|
||||
@@ -4,40 +4,39 @@ import botocore
|
||||
from boto3 import client
|
||||
from flask import current_app
|
||||
|
||||
from app.clients import AWS_CLIENT_CONFIG
|
||||
from app.clients import STATISTICS_DELIVERED, STATISTICS_FAILURE
|
||||
from app.clients.email import (
|
||||
EmailClient,
|
||||
EmailClientException,
|
||||
EmailClientNonRetryableException,
|
||||
)
|
||||
from app.cloudfoundry_config import cloud_config
|
||||
from app.enums import NotificationStatus, StatisticsType
|
||||
|
||||
ses_response_map = {
|
||||
"Permanent": {
|
||||
"message": "Hard bounced",
|
||||
'Permanent': {
|
||||
"message": 'Hard bounced',
|
||||
"success": False,
|
||||
"notification_status": NotificationStatus.PERMANENT_FAILURE,
|
||||
"notification_statistics_status": StatisticsType.FAILURE,
|
||||
"notification_status": 'permanent-failure',
|
||||
"notification_statistics_status": STATISTICS_FAILURE
|
||||
},
|
||||
"Temporary": {
|
||||
"message": "Soft bounced",
|
||||
'Temporary': {
|
||||
"message": 'Soft bounced',
|
||||
"success": False,
|
||||
"notification_status": NotificationStatus.TEMPORARY_FAILURE,
|
||||
"notification_statistics_status": StatisticsType.FAILURE,
|
||||
"notification_status": 'temporary-failure',
|
||||
"notification_statistics_status": STATISTICS_FAILURE
|
||||
},
|
||||
"Delivery": {
|
||||
"message": "Delivered",
|
||||
'Delivery': {
|
||||
"message": 'Delivered',
|
||||
"success": True,
|
||||
"notification_status": NotificationStatus.DELIVERED,
|
||||
"notification_statistics_status": StatisticsType.DELIVERED,
|
||||
"notification_status": 'delivered',
|
||||
"notification_statistics_status": STATISTICS_DELIVERED
|
||||
},
|
||||
"Complaint": {
|
||||
"message": "Complaint",
|
||||
'Complaint': {
|
||||
"message": 'Complaint',
|
||||
"success": True,
|
||||
"notification_status": NotificationStatus.DELIVERED,
|
||||
"notification_statistics_status": StatisticsType.DELIVERED,
|
||||
},
|
||||
"notification_status": 'delivered',
|
||||
"notification_statistics_status": STATISTICS_DELIVERED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,85 +53,88 @@ class AwsSesClientThrottlingSendRateException(AwsSesClientException):
|
||||
|
||||
|
||||
class AwsSesClient(EmailClient):
|
||||
"""
|
||||
'''
|
||||
Amazon SES email client.
|
||||
"""
|
||||
'''
|
||||
|
||||
def init_app(self, *args, **kwargs):
|
||||
def init_app(self, statsd_client, *args, **kwargs):
|
||||
self._client = client(
|
||||
"ses",
|
||||
'ses',
|
||||
region_name=cloud_config.ses_region,
|
||||
aws_access_key_id=cloud_config.ses_access_key,
|
||||
aws_secret_access_key=cloud_config.ses_secret_key,
|
||||
config=AWS_CLIENT_CONFIG,
|
||||
aws_secret_access_key=cloud_config.ses_secret_key
|
||||
)
|
||||
super(AwsSesClient, self).__init__(*args, **kwargs)
|
||||
self.statsd_client = statsd_client
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "ses"
|
||||
return 'ses'
|
||||
|
||||
def send_email(
|
||||
self, source, to_addresses, subject, body, html_body="", reply_to_address=None
|
||||
):
|
||||
def send_email(self,
|
||||
source,
|
||||
to_addresses,
|
||||
subject,
|
||||
body,
|
||||
html_body='',
|
||||
reply_to_address=None):
|
||||
try:
|
||||
if isinstance(to_addresses, str):
|
||||
to_addresses = [to_addresses]
|
||||
|
||||
reply_to_addresses = [reply_to_address] if reply_to_address else []
|
||||
|
||||
body = {"Text": {"Data": body}}
|
||||
body = {
|
||||
'Text': {'Data': body}
|
||||
}
|
||||
|
||||
if html_body:
|
||||
body.update({"Html": {"Data": html_body}})
|
||||
body.update({
|
||||
'Html': {'Data': html_body}
|
||||
})
|
||||
|
||||
start_time = monotonic()
|
||||
response = self._client.send_email(
|
||||
Source=source,
|
||||
Destination={
|
||||
"ToAddresses": [
|
||||
punycode_encode_email(addr) for addr in to_addresses
|
||||
],
|
||||
"CcAddresses": [],
|
||||
"BccAddresses": [],
|
||||
'ToAddresses': [punycode_encode_email(addr) for addr in to_addresses],
|
||||
'CcAddresses': [],
|
||||
'BccAddresses': []
|
||||
},
|
||||
Message={
|
||||
"Subject": {
|
||||
"Data": subject,
|
||||
'Subject': {
|
||||
'Data': subject,
|
||||
},
|
||||
"Body": body,
|
||||
'Body': body
|
||||
},
|
||||
ReplyToAddresses=[
|
||||
punycode_encode_email(addr) for addr in reply_to_addresses
|
||||
],
|
||||
ReplyToAddresses=[punycode_encode_email(addr) for addr in reply_to_addresses]
|
||||
)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
_do_fancy_exception_handling(e)
|
||||
self.statsd_client.incr("clients.ses.error")
|
||||
|
||||
# http://docs.aws.amazon.com/ses/latest/DeveloperGuide/api-error-codes.html
|
||||
if e.response['Error']['Code'] == 'InvalidParameterValue':
|
||||
raise EmailClientNonRetryableException(e.response['Error']['Message'])
|
||||
elif (
|
||||
e.response['Error']['Code'] == 'Throttling'
|
||||
and e.response['Error']['Message'] == 'Maximum sending rate exceeded.'
|
||||
):
|
||||
raise AwsSesClientThrottlingSendRateException(str(e))
|
||||
else:
|
||||
self.statsd_client.incr("clients.ses.error")
|
||||
raise AwsSesClientException(str(e))
|
||||
except Exception as e:
|
||||
self.statsd_client.incr("clients.ses.error")
|
||||
raise AwsSesClientException(str(e))
|
||||
else:
|
||||
elapsed_time = monotonic() - start_time
|
||||
current_app.logger.info(
|
||||
"AWS SES request finished in {}".format(elapsed_time)
|
||||
)
|
||||
return response["MessageId"]
|
||||
current_app.logger.info("AWS SES request finished in {}".format(elapsed_time))
|
||||
self.statsd_client.timing("clients.ses.request-time", elapsed_time)
|
||||
self.statsd_client.incr("clients.ses.success")
|
||||
return response['MessageId']
|
||||
|
||||
|
||||
def punycode_encode_email(email_address):
|
||||
# only the hostname should ever be punycode encoded.
|
||||
local, hostname = email_address.split("@")
|
||||
return "{}@{}".format(local, hostname.encode("idna").decode("utf-8"))
|
||||
|
||||
|
||||
def _do_fancy_exception_handling(e):
|
||||
# http://docs.aws.amazon.com/ses/latest/DeveloperGuide/api-error-codes.html
|
||||
if e.response["Error"]["Code"] == "InvalidParameterValue":
|
||||
raise EmailClientNonRetryableException(e.response["Error"]["Message"])
|
||||
elif (
|
||||
e.response["Error"]["Code"] == "Throttling"
|
||||
and e.response["Error"]["Message"] == "Maximum sending rate exceeded."
|
||||
):
|
||||
raise AwsSesClientThrottlingSendRateException(str(e))
|
||||
else:
|
||||
raise AwsSesClientException(str(e))
|
||||
local, hostname = email_address.split('@')
|
||||
return '{}@{}'.format(local, hostname.encode('idna').decode('utf-8'))
|
||||
|
||||
@@ -12,27 +12,38 @@ class AwsSesStubClientException(EmailClientException):
|
||||
|
||||
|
||||
class AwsSesStubClient(EmailClient):
|
||||
def init_app(self, stub_url):
|
||||
def init_app(self, statsd_client, stub_url):
|
||||
self.statsd_client = statsd_client
|
||||
self.url = stub_url
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "ses"
|
||||
return 'ses'
|
||||
|
||||
def send_email(
|
||||
self, source, to_addresses, subject, body, html_body="", reply_to_address=None
|
||||
):
|
||||
def send_email(self,
|
||||
source,
|
||||
to_addresses,
|
||||
subject,
|
||||
body,
|
||||
html_body='',
|
||||
reply_to_address=None):
|
||||
try:
|
||||
start_time = monotonic()
|
||||
response = request("POST", self.url, data={"id": "dummy-data"}, timeout=60)
|
||||
response = request(
|
||||
"POST",
|
||||
self.url,
|
||||
data={"id": "dummy-data"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = json.loads(response.text)
|
||||
|
||||
except Exception as e:
|
||||
self.statsd_client.incr("clients.ses_stub.error")
|
||||
raise AwsSesStubClientException(str(e))
|
||||
else:
|
||||
elapsed_time = monotonic() - start_time
|
||||
current_app.logger.info(
|
||||
"AWS SES stub request finished in {}".format(elapsed_time)
|
||||
)
|
||||
return response_json["MessageId"]
|
||||
current_app.logger.info("AWS SES stub request finished in {}".format(elapsed_time))
|
||||
self.statsd_client.timing("clients.ses_stub.request-time", elapsed_time)
|
||||
self.statsd_client.incr("clients.ses_stub.success")
|
||||
return response_json['MessageId']
|
||||
|
||||
@@ -3,52 +3,49 @@ import json
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
from notifications_utils.timezones import convert_utc_to_local_timezone
|
||||
|
||||
|
||||
class PerformancePlatformClient:
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
return self._active
|
||||
|
||||
def init_app(self, app, *args, **kwargs):
|
||||
self._active = app.config.get("PERFORMANCE_PLATFORM_ENABLED")
|
||||
def init_app(self, app):
|
||||
self._active = app.config.get('PERFORMANCE_PLATFORM_ENABLED')
|
||||
if self.active:
|
||||
self.performance_platform_url = app.config.get("PERFORMANCE_PLATFORM_URL")
|
||||
self.performance_platform_endpoints = app.config.get(
|
||||
"PERFORMANCE_PLATFORM_ENDPOINTS"
|
||||
)
|
||||
self.performance_platform_url = app.config.get('PERFORMANCE_PLATFORM_URL')
|
||||
self.performance_platform_endpoints = app.config.get('PERFORMANCE_PLATFORM_ENDPOINTS')
|
||||
|
||||
def send_stats_to_performance_platform(self, payload):
|
||||
if self.active:
|
||||
bearer_token = self.performance_platform_endpoints[payload["dataType"]]
|
||||
bearer_token = self.performance_platform_endpoints[payload['dataType']]
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {}".format(bearer_token),
|
||||
'Content-Type': "application/json",
|
||||
'Authorization': 'Bearer {}'.format(bearer_token)
|
||||
}
|
||||
resp = requests.post(
|
||||
self.performance_platform_url + payload["dataType"],
|
||||
self.performance_platform_url + payload['dataType'],
|
||||
json=payload,
|
||||
headers=headers,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
current_app.logger.info(
|
||||
"Updated performance platform successfully with payload {}".format(
|
||||
json.dumps(payload)
|
||||
)
|
||||
"Updated performance platform successfully with payload {}".format(json.dumps(payload))
|
||||
)
|
||||
else:
|
||||
current_app.logger.error(
|
||||
"Performance platform update request failed for payload with response details: {} '{}'".format(
|
||||
json.dumps(payload), resp.status_code
|
||||
json.dumps(payload),
|
||||
resp.status_code
|
||||
)
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
@staticmethod
|
||||
def format_payload(
|
||||
*, dataset, start_time, group_name, group_value, count, period="day"
|
||||
):
|
||||
def format_payload(*, dataset, start_time, group_name, group_value, count, period='day'):
|
||||
"""
|
||||
:param dataset - the name of the overall graph, as referred to in the endpoint.
|
||||
:param start_time - UTC midnight of the day we're sending stats for
|
||||
@@ -58,16 +55,14 @@ class PerformancePlatformClient:
|
||||
:param period - the period that this data covers - "day", "week", "month", "quarter".
|
||||
"""
|
||||
payload = {
|
||||
"_timestamp": start_time,
|
||||
"service": "govuk-notify",
|
||||
"dataType": dataset,
|
||||
"period": period,
|
||||
"count": count,
|
||||
'_timestamp': convert_utc_to_local_timezone(start_time).isoformat(),
|
||||
'service': 'govuk-notify',
|
||||
'dataType': dataset,
|
||||
'period': period,
|
||||
'count': count,
|
||||
group_name: group_value,
|
||||
}
|
||||
payload["_id"] = PerformancePlatformClient.generate_payload_id(
|
||||
payload, group_name
|
||||
)
|
||||
payload['_id'] = PerformancePlatformClient.generate_payload_id(payload, group_name)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
@@ -75,12 +70,12 @@ class PerformancePlatformClient:
|
||||
"""
|
||||
group_name is the name of the group - eg "channel" or "status"
|
||||
"""
|
||||
payload_string = "{}{}{}{}{}".format(
|
||||
payload["_timestamp"],
|
||||
payload["service"],
|
||||
payload_string = '{}{}{}{}{}'.format(
|
||||
payload['_timestamp'],
|
||||
payload['service'],
|
||||
payload[group_name],
|
||||
payload["dataType"],
|
||||
payload["period"],
|
||||
payload['dataType'],
|
||||
payload['period']
|
||||
)
|
||||
_id = base64.b64encode(payload_string.encode("utf-8"))
|
||||
return _id.decode("utf-8")
|
||||
_id = base64.b64encode(payload_string.encode('utf-8'))
|
||||
return _id.decode('utf-8')
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
from abc import abstractmethod, abstractproperty
|
||||
from typing import final
|
||||
|
||||
from app.clients import Client, ClientException
|
||||
|
||||
|
||||
@@ -13,7 +10,7 @@ class SmsClientResponseException(ClientException):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return f"Message {self.message}"
|
||||
return "Message {}".format(self.message)
|
||||
|
||||
|
||||
class SmsClient(Client):
|
||||
@@ -21,14 +18,11 @@ class SmsClient(Client):
|
||||
Base Sms client for sending smss.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def init_app(self, *args, **kwargs):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
|
||||
def send_sms(self, *args, **kwargs):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
|
||||
@abstractproperty
|
||||
def name(self):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
|
||||
@final
|
||||
def get_name(self):
|
||||
return self.name
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import os
|
||||
import re
|
||||
from time import monotonic
|
||||
|
||||
import boto3
|
||||
import botocore
|
||||
import phonenumbers
|
||||
from boto3 import client
|
||||
|
||||
from app.clients import AWS_CLIENT_CONFIG
|
||||
from app.clients.sms import SmsClient
|
||||
from app.cloudfoundry_config import cloud_config
|
||||
|
||||
|
||||
class AwsSnsClient(SmsClient):
|
||||
@@ -16,45 +13,29 @@ class AwsSnsClient(SmsClient):
|
||||
AwsSns sms client
|
||||
"""
|
||||
|
||||
def init_app(self, current_app, *args, **kwargs):
|
||||
if os.getenv("LOCALSTACK_ENDPOINT_URL"):
|
||||
self._client = client(
|
||||
"sns",
|
||||
region_name=cloud_config.sns_region,
|
||||
aws_access_key_id=cloud_config.sns_access_key,
|
||||
aws_secret_access_key=cloud_config.sns_secret_key,
|
||||
config=AWS_CLIENT_CONFIG,
|
||||
endpoint_url=os.getenv("LOCALSTACK_ENDPOINT_URL"),
|
||||
)
|
||||
else:
|
||||
self._client = client(
|
||||
"sns",
|
||||
region_name=cloud_config.sns_region,
|
||||
aws_access_key_id=cloud_config.sns_access_key,
|
||||
aws_secret_access_key=cloud_config.sns_secret_key,
|
||||
config=AWS_CLIENT_CONFIG,
|
||||
)
|
||||
|
||||
def init_app(self, current_app, statsd_client, *args, **kwargs):
|
||||
self._client = boto3.client("sns", region_name=current_app.config["AWS_REGION"])
|
||||
self._long_codes_client = boto3.client("sns", region_name=current_app.config["AWS_PINPOINT_REGION"])
|
||||
super(SmsClient, self).__init__(*args, **kwargs)
|
||||
self.current_app = current_app
|
||||
self._valid_sender_regex = re.compile(r"^\+?\d{5,14}$")
|
||||
self.statsd_client = statsd_client
|
||||
self.long_code_regex = re.compile(r"^\+1\d{10}$")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "sns"
|
||||
return 'sns'
|
||||
|
||||
def _valid_sender_number(self, sender):
|
||||
return sender and re.match(self._valid_sender_regex, sender)
|
||||
def get_name(self):
|
||||
return 'sns'
|
||||
|
||||
def send_sms(self, to, content, reference, sender=None, international=False):
|
||||
matched = False
|
||||
|
||||
for match in phonenumbers.PhoneNumberMatcher(to, "US"):
|
||||
matched = True
|
||||
to = phonenumbers.format_number(
|
||||
match.number, phonenumbers.PhoneNumberFormat.E164
|
||||
)
|
||||
to = phonenumbers.format_number(match.number, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
client = self._client
|
||||
# See documentation
|
||||
# https://docs.aws.amazon.com/sns/latest/dg/sms_publish-to-phone.html#sms_publish_sdk
|
||||
attributes = {
|
||||
@@ -64,12 +45,20 @@ class AwsSnsClient(SmsClient):
|
||||
}
|
||||
}
|
||||
|
||||
if self._valid_sender_number(sender):
|
||||
# If sending with a long code number, we need to use another AWS region
|
||||
# and specify the phone number we want to use as the origination number
|
||||
send_with_dedicated_phone_number = self._send_with_dedicated_phone_number(sender)
|
||||
if send_with_dedicated_phone_number:
|
||||
client = self._long_codes_client
|
||||
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||
"DataType": "String",
|
||||
"StringValue": sender,
|
||||
}
|
||||
else:
|
||||
|
||||
# If the number is US based, we must use a US Toll Free number to send the message
|
||||
country = phonenumbers.region_code_for_number(match.number)
|
||||
if country == "US":
|
||||
client = self._long_codes_client
|
||||
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||
"DataType": "String",
|
||||
"StringValue": self.current_app.config["AWS_US_TOLL_FREE_NUMBER"],
|
||||
@@ -77,22 +66,24 @@ class AwsSnsClient(SmsClient):
|
||||
|
||||
try:
|
||||
start_time = monotonic()
|
||||
response = self._client.publish(
|
||||
PhoneNumber=to, Message=content, MessageAttributes=attributes
|
||||
)
|
||||
response = client.publish(PhoneNumber=to, Message=content, MessageAttributes=attributes)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
self.current_app.logger.error(e)
|
||||
self.statsd_client.incr("clients.sns.error")
|
||||
raise str(e)
|
||||
except Exception as e:
|
||||
self.current_app.logger(e)
|
||||
self.statsd_client.incr("clients.sns.error")
|
||||
raise str(e)
|
||||
finally:
|
||||
elapsed_time = monotonic() - start_time
|
||||
self.current_app.logger.info(
|
||||
"AWS SNS request finished in {}".format(elapsed_time)
|
||||
)
|
||||
self.current_app.logger.info("AWS SNS request finished in {}".format(elapsed_time))
|
||||
self.statsd_client.timing("clients.sns.request-time", elapsed_time)
|
||||
self.statsd_client.incr("clients.sns.success")
|
||||
return response["MessageId"]
|
||||
|
||||
if not matched:
|
||||
self.statsd_client.incr("clients.sns.error")
|
||||
self.current_app.logger.error("No valid numbers found in {}".format(to))
|
||||
raise ValueError("No valid numbers found for SMS delivery")
|
||||
|
||||
def _send_with_dedicated_phone_number(self, sender):
|
||||
return sender and re.match(self.long_code_regex, sender)
|
||||
|
||||
@@ -4,28 +4,29 @@ from os import getenv
|
||||
|
||||
class CloudfoundryConfig:
|
||||
def __init__(self):
|
||||
self.parsed_services = json.loads(getenv("VCAP_SERVICES") or "{}")
|
||||
buckets = self.parsed_services.get("s3") or []
|
||||
self.s3_buckets = {bucket["name"]: bucket["credentials"] for bucket in buckets}
|
||||
self.parsed_services = json.loads(getenv('VCAP_SERVICES') or '{}')
|
||||
buckets = self.parsed_services.get('s3') or []
|
||||
self.s3_buckets = {bucket['name']: bucket['credentials'] for bucket in buckets}
|
||||
self._empty_bucket_credentials = {
|
||||
"bucket": "",
|
||||
"access_key_id": "",
|
||||
"secret_access_key": "",
|
||||
"region": "",
|
||||
'bucket': '',
|
||||
'access_key_id': '',
|
||||
'secret_access_key': '',
|
||||
'region': ''
|
||||
}
|
||||
|
||||
@property
|
||||
def database_url(self):
|
||||
return getenv("DATABASE_URL", "").replace("postgres://", "postgresql://")
|
||||
return getenv('DATABASE_URL', '').replace('postgres://', 'postgresql://')
|
||||
|
||||
@property
|
||||
def redis_url(self):
|
||||
try:
|
||||
return self.parsed_services["aws-elasticache-redis"][0]["credentials"][
|
||||
"uri"
|
||||
].replace("redis://", "rediss://")
|
||||
return self.parsed_services['aws-elasticache-redis'][0]['credentials']['uri'].replace(
|
||||
'redis://',
|
||||
'rediss://'
|
||||
)
|
||||
except KeyError:
|
||||
return getenv("REDIS_URL")
|
||||
return getenv('REDIS_URL')
|
||||
|
||||
def s3_credentials(self, service_name):
|
||||
return self.s3_buckets.get(service_name) or self._empty_bucket_credentials
|
||||
@@ -33,78 +34,44 @@ class CloudfoundryConfig:
|
||||
@property
|
||||
def ses_email_domain(self):
|
||||
try:
|
||||
domain_arn = self._ses_credentials("domain_arn")
|
||||
return self._ses_credentials('domain_arn').split('/')[-1]
|
||||
except KeyError:
|
||||
domain_arn = getenv("SES_DOMAIN_ARN", "dev.notify.gov")
|
||||
return domain_arn.split("/")[-1]
|
||||
|
||||
# TODO remove this after notifications-api #258
|
||||
@property
|
||||
def ses_domain_arn(self):
|
||||
try:
|
||||
domain_arn = self._ses_credentials("domain_arn")
|
||||
except KeyError:
|
||||
domain_arn = getenv("SES_DOMAIN_ARN", "dev.notify.gov")
|
||||
return domain_arn
|
||||
return getenv('NOTIFY_EMAIL_DOMAIN', 'notify.sandbox.10x.gsa.gov')
|
||||
|
||||
@property
|
||||
def ses_region(self):
|
||||
try:
|
||||
return self._ses_credentials("region")
|
||||
return self._ses_credentials('region')
|
||||
except KeyError:
|
||||
return getenv("SES_AWS_REGION", "us-west-1")
|
||||
return getenv('AWS_REGION')
|
||||
|
||||
@property
|
||||
def ses_access_key(self):
|
||||
try:
|
||||
return self._ses_credentials("smtp_user")
|
||||
return self._ses_credentials('smtp_user')
|
||||
except KeyError:
|
||||
return getenv("SES_AWS_ACCESS_KEY_ID")
|
||||
return getenv('AWS_ACCESS_KEY_ID')
|
||||
|
||||
@property
|
||||
def ses_secret_key(self):
|
||||
try:
|
||||
return self._ses_credentials("secret_access_key")
|
||||
return self._ses_credentials('secret_access_key')
|
||||
except KeyError:
|
||||
return getenv("SES_AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
@property
|
||||
def sns_access_key(self):
|
||||
try:
|
||||
return self._sns_credentials("aws_access_key_id")
|
||||
except KeyError:
|
||||
return getenv("SNS_AWS_ACCESS_KEY_ID")
|
||||
|
||||
@property
|
||||
def sns_secret_key(self):
|
||||
try:
|
||||
return self._sns_credentials("aws_secret_access_key")
|
||||
except KeyError:
|
||||
return getenv("SNS_AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
@property
|
||||
def sns_region(self):
|
||||
try:
|
||||
return self._sns_credentials("region")
|
||||
except KeyError:
|
||||
return getenv("SNS_AWS_REGION", "us-west-1")
|
||||
return getenv('AWS_SECRET_ACCESS_KEY')
|
||||
|
||||
@property
|
||||
def sns_topic_arns(self):
|
||||
try:
|
||||
return [
|
||||
self._ses_credentials("bounce_topic_arn"),
|
||||
self._ses_credentials("complaint_topic_arn"),
|
||||
self._ses_credentials("delivery_topic_arn"),
|
||||
self._ses_credentials('bounce_topic_arn'),
|
||||
self._ses_credentials('complaint_topic_arn'),
|
||||
self._ses_credentials('delivery_topic_arn')
|
||||
]
|
||||
except KeyError:
|
||||
return []
|
||||
|
||||
def _ses_credentials(self, key):
|
||||
return self.parsed_services["datagov-smtp"][0]["credentials"][key]
|
||||
|
||||
def _sns_credentials(self, key):
|
||||
return self.parsed_services["ttsnotify-sms"][0]["credentials"][key]
|
||||
return self.parsed_services['datagov-smtp'][0]['credentials'][key]
|
||||
|
||||
|
||||
cloud_config = CloudfoundryConfig()
|
||||
|
||||
782
app/commands.py
782
app/commands.py
File diff suppressed because it is too large
Load Diff
@@ -3,33 +3,35 @@ from datetime import datetime
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from app.complaint.complaint_schema import complaint_count_request
|
||||
from app.dao.complaint_dao import fetch_count_of_complaints, fetch_paginated_complaints
|
||||
from app.dao.complaint_dao import (
|
||||
fetch_count_of_complaints,
|
||||
fetch_paginated_complaints,
|
||||
)
|
||||
from app.errors import register_errors
|
||||
from app.schema_validation import validate
|
||||
from app.utils import pagination_links
|
||||
|
||||
complaint_blueprint = Blueprint("complaint", __name__, url_prefix="/complaint")
|
||||
complaint_blueprint = Blueprint('complaint', __name__, url_prefix='/complaint')
|
||||
|
||||
register_errors(complaint_blueprint)
|
||||
|
||||
|
||||
@complaint_blueprint.route("", methods=["GET"])
|
||||
@complaint_blueprint.route('', methods=['GET'])
|
||||
def get_all_complaints():
|
||||
page = int(request.args.get("page", 1))
|
||||
page = int(request.args.get('page', 1))
|
||||
pagination = fetch_paginated_complaints(page=page)
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
complaints=[x.serialize() for x in pagination.items],
|
||||
links=pagination_links(
|
||||
pagination, ".get_all_complaints", **request.args.to_dict()
|
||||
),
|
||||
),
|
||||
200,
|
||||
)
|
||||
return jsonify(
|
||||
complaints=[x.serialize() for x in pagination.items],
|
||||
links=pagination_links(
|
||||
pagination,
|
||||
'.get_all_complaints',
|
||||
**request.args.to_dict()
|
||||
)
|
||||
), 200
|
||||
|
||||
|
||||
@complaint_blueprint.route("/count-by-date-range", methods=["GET"])
|
||||
@complaint_blueprint.route('/count-by-date-range', methods=['GET'])
|
||||
def get_complaint_count():
|
||||
if request.args:
|
||||
validate(request.args, complaint_count_request)
|
||||
@@ -37,12 +39,8 @@ def get_complaint_count():
|
||||
# If start and end date are not set, we are expecting today's stats.
|
||||
today = str(datetime.utcnow().date())
|
||||
|
||||
start_date = datetime.strptime(
|
||||
request.args.get("start_date", today), "%Y-%m-%d"
|
||||
).date()
|
||||
end_date = datetime.strptime(request.args.get("end_date", today), "%Y-%m-%d").date()
|
||||
count_of_complaints = fetch_count_of_complaints(
|
||||
start_date=start_date, end_date=end_date
|
||||
)
|
||||
start_date = datetime.strptime(request.args.get('start_date', today), '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(request.args.get('end_date', today), '%Y-%m-%d').date()
|
||||
count_of_complaints = fetch_count_of_complaints(start_date=start_date, end_date=end_date)
|
||||
|
||||
return jsonify(count_of_complaints), 200
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
complaint_count_request = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "complaint count request schema",
|
||||
@@ -6,5 +7,5 @@ complaint_count_request = {
|
||||
"properties": {
|
||||
"start_date": {"type": ["string", "null"], "format": "date"},
|
||||
"end_date": {"type": ["string", "null"], "format": "date"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
398
app/config.py
398
app/config.py
@@ -2,7 +2,6 @@ import json
|
||||
from datetime import timedelta
|
||||
from os import getenv, path
|
||||
|
||||
import notifications_utils
|
||||
from celery.schedules import crontab
|
||||
from kombu import Exchange, Queue
|
||||
|
||||
@@ -10,22 +9,22 @@ from app.cloudfoundry_config import cloud_config
|
||||
|
||||
|
||||
class QueueNames(object):
|
||||
PERIODIC = "periodic-tasks"
|
||||
PRIORITY = "priority-tasks"
|
||||
DATABASE = "database-tasks"
|
||||
SEND_SMS = "send-sms-tasks"
|
||||
CHECK_SMS = "check-sms_tasks"
|
||||
SEND_EMAIL = "send-email-tasks"
|
||||
REPORTING = "reporting-tasks"
|
||||
JOBS = "job-tasks"
|
||||
RETRY = "retry-tasks"
|
||||
NOTIFY = "notify-internal-tasks"
|
||||
CALLBACKS = "service-callbacks"
|
||||
CALLBACKS_RETRY = "service-callbacks-retry"
|
||||
SMS_CALLBACKS = "sms-callbacks"
|
||||
ANTIVIRUS = "antivirus-tasks"
|
||||
SAVE_API_EMAIL = "save-api-email-tasks"
|
||||
SAVE_API_SMS = "save-api-sms-tasks"
|
||||
PERIODIC = 'periodic-tasks'
|
||||
PRIORITY = 'priority-tasks'
|
||||
DATABASE = 'database-tasks'
|
||||
SEND_SMS = 'send-sms-tasks'
|
||||
SEND_EMAIL = 'send-email-tasks'
|
||||
RESEARCH_MODE = 'research-mode-tasks'
|
||||
REPORTING = 'reporting-tasks'
|
||||
JOBS = 'job-tasks'
|
||||
RETRY = 'retry-tasks'
|
||||
NOTIFY = 'notify-internal-tasks'
|
||||
CALLBACKS = 'service-callbacks'
|
||||
CALLBACKS_RETRY = 'service-callbacks-retry'
|
||||
SMS_CALLBACKS = 'sms-callbacks'
|
||||
ANTIVIRUS = 'antivirus-tasks'
|
||||
SAVE_API_EMAIL = 'save-api-email-tasks'
|
||||
SAVE_API_SMS = 'save-api-sms-tasks'
|
||||
|
||||
@staticmethod
|
||||
def all_queues():
|
||||
@@ -34,8 +33,8 @@ class QueueNames(object):
|
||||
QueueNames.PERIODIC,
|
||||
QueueNames.DATABASE,
|
||||
QueueNames.SEND_SMS,
|
||||
QueueNames.CHECK_SMS,
|
||||
QueueNames.SEND_EMAIL,
|
||||
QueueNames.RESEARCH_MODE,
|
||||
QueueNames.REPORTING,
|
||||
QueueNames.JOBS,
|
||||
QueueNames.RETRY,
|
||||
@@ -49,323 +48,298 @@ class QueueNames(object):
|
||||
|
||||
|
||||
class TaskNames(object):
|
||||
PROCESS_INCOMPLETE_JOBS = "process-incomplete-jobs"
|
||||
SCAN_FILE = "scan-file"
|
||||
PROCESS_INCOMPLETE_JOBS = 'process-incomplete-jobs'
|
||||
SCAN_FILE = 'scan-file'
|
||||
|
||||
|
||||
class Config(object):
|
||||
NOTIFY_APP_NAME = "api"
|
||||
NOTIFY_ENVIRONMENT = getenv("NOTIFY_ENVIRONMENT", "development")
|
||||
NOTIFY_APP_NAME = 'api'
|
||||
NOTIFY_ENVIRONMENT = getenv('NOTIFY_ENVIRONMENT', 'development')
|
||||
# URL of admin app
|
||||
ADMIN_BASE_URL = getenv("ADMIN_BASE_URL", "http://localhost:6012")
|
||||
ADMIN_BASE_URL = getenv('ADMIN_BASE_URL', 'http://localhost:6012')
|
||||
# URL of api app (on AWS this is the internal api endpoint)
|
||||
API_HOST_NAME = getenv("API_HOST_NAME", "http://localhost:6011")
|
||||
API_HOST_NAME = getenv('API_HOST_NAME', 'http://localhost:6011')
|
||||
|
||||
# Credentials
|
||||
# secrets that internal apps, such as the admin app or document download, must use to authenticate with the API
|
||||
# ADMIN_CLIENT_ID is called ADMIN_CLIENT_USER_NAME in api repo, they should match
|
||||
ADMIN_CLIENT_ID = getenv("ADMIN_CLIENT_ID", "notify-admin")
|
||||
ADMIN_CLIENT_ID = getenv('ADMIN_CLIENT_ID', 'notify-admin')
|
||||
INTERNAL_CLIENT_API_KEYS = json.loads(
|
||||
getenv(
|
||||
"INTERNAL_CLIENT_API_KEYS",
|
||||
('{"%s":["%s"]}' % (ADMIN_CLIENT_ID, getenv("ADMIN_CLIENT_SECRET"))),
|
||||
)
|
||||
'INTERNAL_CLIENT_API_KEYS',
|
||||
('{"%s":["%s"]}' % (ADMIN_CLIENT_ID, getenv('ADMIN_CLIENT_SECRET')))
|
||||
)
|
||||
)
|
||||
ALLOW_EXPIRED_API_TOKEN = False
|
||||
# encyption secret/salt
|
||||
SECRET_KEY = getenv("SECRET_KEY")
|
||||
DANGEROUS_SALT = getenv("DANGEROUS_SALT")
|
||||
ROUTE_SECRET_KEY_1 = getenv("ROUTE_SECRET_KEY_1", "dev-route-secret-key-1")
|
||||
ROUTE_SECRET_KEY_2 = getenv("ROUTE_SECRET_KEY_2", "dev-route-secret-key-2")
|
||||
SECRET_KEY = getenv('SECRET_KEY')
|
||||
DANGEROUS_SALT = getenv('DANGEROUS_SALT')
|
||||
ROUTE_SECRET_KEY_1 = getenv('ROUTE_SECRET_KEY_1', 'dev-route-secret-key-1')
|
||||
ROUTE_SECRET_KEY_2 = getenv('ROUTE_SECRET_KEY_2', 'dev-route-secret-key-2')
|
||||
|
||||
# DB settings
|
||||
SQLALCHEMY_DATABASE_URI = cloud_config.database_url
|
||||
SQLALCHEMY_RECORD_QUERIES = False
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_POOL_SIZE = int(getenv("SQLALCHEMY_POOL_SIZE", 5))
|
||||
SQLALCHEMY_POOL_SIZE = int(getenv('SQLALCHEMY_POOL_SIZE', 5))
|
||||
SQLALCHEMY_POOL_TIMEOUT = 30
|
||||
SQLALCHEMY_POOL_RECYCLE = 300
|
||||
SQLALCHEMY_STATEMENT_TIMEOUT = 1200
|
||||
PAGE_SIZE = 50
|
||||
API_PAGE_SIZE = 250
|
||||
REDIS_URL = cloud_config.redis_url
|
||||
REDIS_ENABLED = getenv("REDIS_ENABLED", "1") == "1"
|
||||
REDIS_ENABLED = getenv('REDIS_ENABLED', '0') == '1'
|
||||
EXPIRE_CACHE_TEN_MINUTES = 600
|
||||
EXPIRE_CACHE_EIGHT_DAYS = 8 * 24 * 60 * 60
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION = getenv('AWS_REGION')
|
||||
AWS_PINPOINT_REGION = getenv("AWS_PINPOINT_REGION")
|
||||
AWS_US_TOLL_FREE_NUMBER = getenv("AWS_US_TOLL_FREE_NUMBER")
|
||||
# Whether to ignore POSTs from SNS for replies to SMS we sent
|
||||
RECEIVE_INBOUND_SMS = False
|
||||
NOTIFY_EMAIL_DOMAIN = cloud_config.ses_email_domain
|
||||
SES_STUB_URL = (
|
||||
None # TODO: set to a URL in env and remove this to use a stubbed SES service
|
||||
)
|
||||
SES_STUB_URL = None # TODO: set to a URL in env and remove this to use a stubbed SES service
|
||||
# AWS SNS topics for delivery receipts
|
||||
VALIDATE_SNS_TOPICS = True
|
||||
VALID_SNS_TOPICS = cloud_config.sns_topic_arns
|
||||
|
||||
# these should always add up to 100%
|
||||
SMS_PROVIDER_RESTING_POINTS = {
|
||||
"sns": 100,
|
||||
'sns': 100,
|
||||
}
|
||||
|
||||
# Zendesk
|
||||
ZENDESK_API_KEY = getenv("ZENDESK_API_KEY")
|
||||
ZENDESK_API_KEY = getenv('ZENDESK_API_KEY')
|
||||
|
||||
# Logging
|
||||
DEBUG = False
|
||||
|
||||
# Monitoring
|
||||
CRONITOR_ENABLED = False
|
||||
CRONITOR_KEYS = json.loads(getenv("CRONITOR_KEYS", "{}"))
|
||||
CRONITOR_KEYS = json.loads(getenv('CRONITOR_KEYS', '{}'))
|
||||
STATSD_HOST = getenv('STATSD_HOST')
|
||||
STATSD_PORT = 8125
|
||||
STATSD_ENABLED = bool(STATSD_HOST)
|
||||
|
||||
# Antivirus
|
||||
ANTIVIRUS_ENABLED = getenv("ANTIVIRUS_ENABLED", "1") == "1"
|
||||
ANTIVIRUS_ENABLED = getenv('ANTIVIRUS_ENABLED', '1') == '1'
|
||||
|
||||
SENDING_NOTIFICATIONS_TIMEOUT_PERIOD = 259200 # 3 days
|
||||
INVITATION_EXPIRATION_DAYS = 2
|
||||
TEST_MESSAGE_FILENAME = "Test message"
|
||||
ONE_OFF_MESSAGE_FILENAME = "Report"
|
||||
TEST_MESSAGE_FILENAME = 'Test message'
|
||||
ONE_OFF_MESSAGE_FILENAME = 'Report'
|
||||
MAX_VERIFY_CODE_COUNT = 5
|
||||
MAX_FAILED_LOGIN_COUNT = 10
|
||||
API_RATE_LIMIT_ENABLED = True
|
||||
|
||||
# Default data
|
||||
CONFIG_FILES = path.dirname(__file__) + "/config_files/"
|
||||
CONFIG_FILES = path.dirname(__file__) + '/config_files/'
|
||||
|
||||
NOTIFY_SERVICE_ID = "d6aa2c68-a2d9-4437-ab19-3ae8eb202553"
|
||||
NOTIFY_USER_ID = "6af522d0-2915-4e52-83a3-3690455a5fe6"
|
||||
INVITATION_EMAIL_TEMPLATE_ID = "4f46df42-f795-4cc4-83bb-65ca312f49cc"
|
||||
SMS_CODE_TEMPLATE_ID = "36fb0730-6259-4da1-8a80-c8de22ad4246"
|
||||
EMAIL_2FA_TEMPLATE_ID = "299726d2-dba6-42b8-8209-30e1d66ea164"
|
||||
NEW_USER_EMAIL_VERIFICATION_TEMPLATE_ID = "ece42649-22a8-4d06-b87f-d52d5d3f0a27"
|
||||
PASSWORD_RESET_TEMPLATE_ID = (
|
||||
"474e9242-823b-4f99-813d-ed392e7f1201" # nosec B105 - this is not a password
|
||||
)
|
||||
ALREADY_REGISTERED_EMAIL_TEMPLATE_ID = "0880fbb1-a0c6-46f0-9a8e-36c986381ceb"
|
||||
CHANGE_EMAIL_CONFIRMATION_TEMPLATE_ID = "eb4d9930-87ab-4aef-9bce-786762687884"
|
||||
SERVICE_NOW_LIVE_TEMPLATE_ID = "618185c6-3636-49cd-b7d2-6f6f5eb3bdde"
|
||||
ORGANIZATION_INVITATION_EMAIL_TEMPLATE_ID = "203566f0-d835-47c5-aa06-932439c86573"
|
||||
TEAM_MEMBER_EDIT_EMAIL_TEMPLATE_ID = "c73f1d71-4049-46d5-a647-d013bdeca3f0"
|
||||
TEAM_MEMBER_EDIT_MOBILE_TEMPLATE_ID = "8a31520f-4751-4789-8ea1-fe54496725eb"
|
||||
REPLY_TO_EMAIL_ADDRESS_VERIFICATION_TEMPLATE_ID = (
|
||||
"a42f1d17-9404-46d5-a647-d013bdfca3e1"
|
||||
)
|
||||
MOU_SIGNER_RECEIPT_TEMPLATE_ID = "4fd2e43c-309b-4e50-8fb8-1955852d9d71"
|
||||
MOU_SIGNED_ON_BEHALF_SIGNER_RECEIPT_TEMPLATE_ID = (
|
||||
"c20206d5-bf03-4002-9a90-37d5032d9e84"
|
||||
)
|
||||
MOU_SIGNED_ON_BEHALF_ON_BEHALF_RECEIPT_TEMPLATE_ID = (
|
||||
"522b6657-5ca5-4368-a294-6b527703bd0b"
|
||||
)
|
||||
NOTIFY_INTERNATIONAL_SMS_SENDER = getenv("AWS_US_TOLL_FREE_NUMBER")
|
||||
LETTERS_VOLUME_EMAIL_TEMPLATE_ID = "11fad854-fd38-4a7c-bd17-805fb13dfc12"
|
||||
NHS_EMAIL_BRANDING_ID = "a7dc4e56-660b-4db7-8cff-12c37b12b5ea"
|
||||
NOTIFY_SERVICE_ID = 'd6aa2c68-a2d9-4437-ab19-3ae8eb202553'
|
||||
NOTIFY_USER_ID = '6af522d0-2915-4e52-83a3-3690455a5fe6'
|
||||
INVITATION_EMAIL_TEMPLATE_ID = '4f46df42-f795-4cc4-83bb-65ca312f49cc'
|
||||
SMS_CODE_TEMPLATE_ID = '36fb0730-6259-4da1-8a80-c8de22ad4246'
|
||||
EMAIL_2FA_TEMPLATE_ID = '299726d2-dba6-42b8-8209-30e1d66ea164'
|
||||
NEW_USER_EMAIL_VERIFICATION_TEMPLATE_ID = 'ece42649-22a8-4d06-b87f-d52d5d3f0a27'
|
||||
PASSWORD_RESET_TEMPLATE_ID = '474e9242-823b-4f99-813d-ed392e7f1201' # nosec B105 - this is not a password
|
||||
ALREADY_REGISTERED_EMAIL_TEMPLATE_ID = '0880fbb1-a0c6-46f0-9a8e-36c986381ceb'
|
||||
CHANGE_EMAIL_CONFIRMATION_TEMPLATE_ID = 'eb4d9930-87ab-4aef-9bce-786762687884'
|
||||
SERVICE_NOW_LIVE_TEMPLATE_ID = '618185c6-3636-49cd-b7d2-6f6f5eb3bdde'
|
||||
ORGANISATION_INVITATION_EMAIL_TEMPLATE_ID = '203566f0-d835-47c5-aa06-932439c86573'
|
||||
TEAM_MEMBER_EDIT_EMAIL_TEMPLATE_ID = 'c73f1d71-4049-46d5-a647-d013bdeca3f0'
|
||||
TEAM_MEMBER_EDIT_MOBILE_TEMPLATE_ID = '8a31520f-4751-4789-8ea1-fe54496725eb'
|
||||
REPLY_TO_EMAIL_ADDRESS_VERIFICATION_TEMPLATE_ID = 'a42f1d17-9404-46d5-a647-d013bdfca3e1'
|
||||
MOU_SIGNER_RECEIPT_TEMPLATE_ID = '4fd2e43c-309b-4e50-8fb8-1955852d9d71'
|
||||
MOU_SIGNED_ON_BEHALF_SIGNER_RECEIPT_TEMPLATE_ID = 'c20206d5-bf03-4002-9a90-37d5032d9e84'
|
||||
MOU_SIGNED_ON_BEHALF_ON_BEHALF_RECEIPT_TEMPLATE_ID = '522b6657-5ca5-4368-a294-6b527703bd0b'
|
||||
NOTIFY_INTERNATIONAL_SMS_SENDER = '18446120782'
|
||||
LETTERS_VOLUME_EMAIL_TEMPLATE_ID = '11fad854-fd38-4a7c-bd17-805fb13dfc12'
|
||||
NHS_EMAIL_BRANDING_ID = 'a7dc4e56-660b-4db7-8cff-12c37b12b5ea'
|
||||
# we only need real email in Live environment (production)
|
||||
DVLA_EMAIL_ADDRESSES = json.loads(getenv("DVLA_EMAIL_ADDRESSES", "[]"))
|
||||
DVLA_EMAIL_ADDRESSES = json.loads(getenv('DVLA_EMAIL_ADDRESSES', '[]'))
|
||||
|
||||
CELERY = {
|
||||
"broker_url": REDIS_URL,
|
||||
"broker_transport_options": {
|
||||
"visibility_timeout": 310,
|
||||
'broker_url': REDIS_URL,
|
||||
'broker_transport_options': {
|
||||
'visibility_timeout': 310,
|
||||
},
|
||||
"timezone": getenv("TIMEZONE", "UTC"),
|
||||
"imports": [
|
||||
"app.celery.tasks",
|
||||
"app.celery.scheduled_tasks",
|
||||
"app.celery.reporting_tasks",
|
||||
"app.celery.nightly_tasks",
|
||||
'timezone': getenv("TIMEZONE", 'America/New_York'),
|
||||
'imports': [
|
||||
'app.celery.tasks',
|
||||
'app.celery.scheduled_tasks',
|
||||
'app.celery.reporting_tasks',
|
||||
'app.celery.nightly_tasks',
|
||||
],
|
||||
# this is overriden by the -Q command, but locally, we should read from all queues
|
||||
"task_queues": [
|
||||
Queue(queue, Exchange("default"), routing_key=queue)
|
||||
for queue in QueueNames.all_queues()
|
||||
'task_queues': [
|
||||
Queue(queue, Exchange('default'), routing_key=queue) for queue in QueueNames.all_queues()
|
||||
],
|
||||
"beat_schedule": {
|
||||
'beat_schedule': {
|
||||
# app/celery/scheduled_tasks.py
|
||||
"run-scheduled-jobs": {
|
||||
"task": "run-scheduled-jobs",
|
||||
"schedule": crontab(minute="0,15,30,45"),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'run-scheduled-jobs': {
|
||||
'task': 'run-scheduled-jobs',
|
||||
'schedule': crontab(minute='0,15,30,45'),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"delete-verify-codes": {
|
||||
"task": "delete-verify-codes",
|
||||
"schedule": timedelta(minutes=63),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'delete-verify-codes': {
|
||||
'task': 'delete-verify-codes',
|
||||
'schedule': timedelta(minutes=63),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"expire-or-delete-invitations": {
|
||||
"task": "expire-or-delete-invitations",
|
||||
"schedule": timedelta(minutes=66),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'delete-invitations': {
|
||||
'task': 'delete-invitations',
|
||||
'schedule': timedelta(minutes=66),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"check-db-notification-fails": {
|
||||
"task": "check-db-notification-fails",
|
||||
"schedule": crontab(minute="18, 48"),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'check-job-status': {
|
||||
'task': 'check-job-status',
|
||||
'schedule': crontab(),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"check-job-status": {
|
||||
"task": "check-job-status",
|
||||
"schedule": crontab(),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'tend-providers-back-to-middle': {
|
||||
'task': 'tend-providers-back-to-middle',
|
||||
'schedule': crontab(minute='*/5'),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"check-for-missing-rows-in-completed-jobs": {
|
||||
"task": "check-for-missing-rows-in-completed-jobs",
|
||||
"schedule": crontab(minute="*/10"),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'check-for-missing-rows-in-completed-jobs': {
|
||||
'task': 'check-for-missing-rows-in-completed-jobs',
|
||||
'schedule': crontab(minute='*/10'),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"replay-created-notifications": {
|
||||
"task": "replay-created-notifications",
|
||||
"schedule": crontab(minute="0, 15, 30, 45"),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'replay-created-notifications': {
|
||||
'task': 'replay-created-notifications',
|
||||
'schedule': crontab(minute='0, 15, 30, 45'),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
# app/celery/nightly_tasks.py
|
||||
"timeout-sending-notifications": {
|
||||
"task": "timeout-sending-notifications",
|
||||
"schedule": crontab(hour=4, minute=5),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'timeout-sending-notifications': {
|
||||
'task': 'timeout-sending-notifications',
|
||||
'schedule': crontab(hour=0, minute=5),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"create-nightly-billing": {
|
||||
"task": "create-nightly-billing",
|
||||
"schedule": crontab(hour=4, minute=15),
|
||||
"options": {"queue": QueueNames.REPORTING},
|
||||
'create-nightly-billing': {
|
||||
'task': 'create-nightly-billing',
|
||||
'schedule': crontab(hour=0, minute=15),
|
||||
'options': {'queue': QueueNames.REPORTING}
|
||||
},
|
||||
"create-nightly-notification-status": {
|
||||
"task": "create-nightly-notification-status",
|
||||
"schedule": crontab(
|
||||
hour=4, minute=30
|
||||
), # after 'timeout-sending-notifications'
|
||||
"options": {"queue": QueueNames.REPORTING},
|
||||
'create-nightly-notification-status': {
|
||||
'task': 'create-nightly-notification-status',
|
||||
'schedule': crontab(hour=0, minute=30), # after 'timeout-sending-notifications'
|
||||
'options': {'queue': QueueNames.REPORTING}
|
||||
},
|
||||
"delete-notifications-older-than-retention": {
|
||||
"task": "delete-notifications-older-than-retention",
|
||||
"schedule": crontab(
|
||||
hour=7, minute=0
|
||||
), # after 'create-nightly-notification-status'
|
||||
"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'
|
||||
'options': {'queue': QueueNames.REPORTING}
|
||||
},
|
||||
"delete-inbound-sms": {
|
||||
"task": "delete-inbound-sms",
|
||||
"schedule": crontab(hour=5, minute=40),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'delete-inbound-sms': {
|
||||
'task': 'delete-inbound-sms',
|
||||
'schedule': crontab(hour=1, minute=40),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"save-daily-notification-processing-time": {
|
||||
"task": "save-daily-notification-processing-time",
|
||||
"schedule": crontab(hour=6, minute=0),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'save-daily-notification-processing-time': {
|
||||
'task': 'save-daily-notification-processing-time',
|
||||
'schedule': crontab(hour=2, minute=0),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"cleanup-unfinished-jobs": {
|
||||
"task": "cleanup-unfinished-jobs",
|
||||
"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),
|
||||
'options': {'queue': QueueNames.PERIODIC},
|
||||
},
|
||||
"remove_sms_email_jobs": {
|
||||
"task": "remove_sms_email_jobs",
|
||||
"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),
|
||||
'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=14, minute=30),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# we can set celeryd_prefetch_multiplier to be 1 for celery apps which handle only long running tasks
|
||||
if getenv("CELERYD_PREFETCH_MULTIPLIER"):
|
||||
CELERY["worker_prefetch_multiplier"] = getenv("CELERYD_PREFETCH_MULTIPLIER")
|
||||
if getenv('CELERYD_PREFETCH_MULTIPLIER'):
|
||||
CELERY['worker_prefetch_multiplier'] = getenv('CELERYD_PREFETCH_MULTIPLIER')
|
||||
|
||||
FROM_NUMBER = "development"
|
||||
FROM_NUMBER = 'development'
|
||||
|
||||
SIMULATED_EMAIL_ADDRESSES = (
|
||||
"simulate-delivered@notifications.service.gov.uk",
|
||||
"simulate-delivered-2@notifications.service.gov.uk",
|
||||
"simulate-delivered-3@notifications.service.gov.uk",
|
||||
'simulate-delivered@notifications.service.gov.uk',
|
||||
'simulate-delivered-2@notifications.service.gov.uk',
|
||||
'simulate-delivered-3@notifications.service.gov.uk',
|
||||
)
|
||||
# 7755 is success, 7167 is failure
|
||||
SIMULATED_SMS_NUMBERS = ("+14254147755", "+14254147167")
|
||||
SIMULATED_SMS_NUMBERS = ('+12028675000', '+12028675111', '+12028675222')
|
||||
|
||||
FREE_SMS_TIER_FRAGMENT_COUNT = 250000
|
||||
|
||||
TOTAL_MESSAGE_LIMIT = 250000
|
||||
HIGH_VOLUME_SERVICE = json.loads(getenv('HIGH_VOLUME_SERVICE', '[]'))
|
||||
|
||||
DAILY_MESSAGE_LIMIT = notifications_utils.DAILY_MESSAGE_LIMIT
|
||||
TEMPLATE_PREVIEW_API_HOST = getenv('TEMPLATE_PREVIEW_API_HOST', 'http://localhost:6013')
|
||||
TEMPLATE_PREVIEW_API_KEY = getenv('TEMPLATE_PREVIEW_API_KEY', 'my-secret-key')
|
||||
|
||||
HIGH_VOLUME_SERVICE = json.loads(getenv("HIGH_VOLUME_SERVICE", "[]"))
|
||||
|
||||
TEMPLATE_PREVIEW_API_HOST = getenv(
|
||||
"TEMPLATE_PREVIEW_API_HOST", "http://localhost:6013"
|
||||
)
|
||||
TEMPLATE_PREVIEW_API_KEY = getenv("TEMPLATE_PREVIEW_API_KEY", "my-secret-key")
|
||||
|
||||
DOCUMENT_DOWNLOAD_API_HOST = getenv(
|
||||
"DOCUMENT_DOWNLOAD_API_HOST", "http://localhost:7000"
|
||||
)
|
||||
DOCUMENT_DOWNLOAD_API_KEY = getenv("DOCUMENT_DOWNLOAD_API_KEY", "auth-token")
|
||||
DOCUMENT_DOWNLOAD_API_HOST = getenv('DOCUMENT_DOWNLOAD_API_HOST', 'http://localhost:7000')
|
||||
DOCUMENT_DOWNLOAD_API_KEY = getenv('DOCUMENT_DOWNLOAD_API_KEY', 'auth-token')
|
||||
|
||||
|
||||
def _s3_credentials_from_env(bucket_prefix):
|
||||
def _default_s3_credentials(bucket_name):
|
||||
return {
|
||||
"bucket": getenv(f"{bucket_prefix}_BUCKET_NAME"),
|
||||
"access_key_id": getenv(f"{bucket_prefix}_AWS_ACCESS_KEY_ID"),
|
||||
"secret_access_key": getenv(f"{bucket_prefix}_AWS_SECRET_ACCESS_KEY"),
|
||||
"region": getenv(f"{bucket_prefix}_AWS_REGION"),
|
||||
'bucket': bucket_name,
|
||||
'access_key_id': getenv('AWS_ACCESS_KEY_ID'),
|
||||
'secret_access_key': getenv('AWS_SECRET_ACCESS_KEY'),
|
||||
'region': getenv('AWS_REGION')
|
||||
}
|
||||
|
||||
|
||||
class Development(Config):
|
||||
DEBUG = True
|
||||
NOTIFY_LOG_LEVEL = "DEBUG"
|
||||
SQLALCHEMY_ECHO = False
|
||||
DVLA_EMAIL_ADDRESSES = ["success@simulator.amazonses.com"]
|
||||
DVLA_EMAIL_ADDRESSES = ['success@simulator.amazonses.com']
|
||||
|
||||
# Buckets
|
||||
CSV_UPLOAD_BUCKET = _s3_credentials_from_env("CSV")
|
||||
CSV_UPLOAD_BUCKET = _default_s3_credentials('local-notifications-csv-upload')
|
||||
CONTACT_LIST_BUCKET = _default_s3_credentials('local-contact-list')
|
||||
|
||||
# credential overrides
|
||||
DANGEROUS_SALT = "development-notify-salt"
|
||||
SECRET_KEY = (
|
||||
"dev-notify-secret-key" # nosec B105 - this is only used in development
|
||||
)
|
||||
INTERNAL_CLIENT_API_KEYS = {Config.ADMIN_CLIENT_ID: ["dev-notify-secret-key"]}
|
||||
ALLOW_EXPIRED_API_TOKEN = getenv("ALLOW_EXPIRED_API_TOKEN", "0") == "1"
|
||||
DANGEROUS_SALT = 'development-notify-salt'
|
||||
SECRET_KEY = 'dev-notify-secret-key' # nosec B105 - this is only used in development
|
||||
INTERNAL_CLIENT_API_KEYS = {Config.ADMIN_CLIENT_ID: ['dev-notify-secret-key']}
|
||||
ALLOW_EXPIRED_API_TOKEN = getenv('ALLOW_EXPIRED_API_TOKEN', '0') == '1'
|
||||
|
||||
|
||||
class Test(Development):
|
||||
FROM_NUMBER = "testing"
|
||||
FROM_NUMBER = 'testing'
|
||||
TESTING = True
|
||||
ANTIVIRUS_ENABLED = True
|
||||
DVLA_EMAIL_ADDRESSES = [
|
||||
"success@simulator.amazonses.com",
|
||||
"success+2@simulator.amazonses.com",
|
||||
]
|
||||
DVLA_EMAIL_ADDRESSES = ['success@simulator.amazonses.com', 'success+2@simulator.amazonses.com']
|
||||
|
||||
HIGH_VOLUME_SERVICE = [
|
||||
"941b6f9a-50d7-4742-8d50-f365ca74bf27",
|
||||
"63f95b86-2d19-4497-b8b2-ccf25457df4e",
|
||||
"7e5950cb-9954-41f5-8376-962b8c8555cf",
|
||||
"10d1b9c9-0072-4fa9-ae1c-595e333841da",
|
||||
'941b6f9a-50d7-4742-8d50-f365ca74bf27',
|
||||
'63f95b86-2d19-4497-b8b2-ccf25457df4e',
|
||||
'7e5950cb-9954-41f5-8376-962b8c8555cf',
|
||||
'10d1b9c9-0072-4fa9-ae1c-595e333841da',
|
||||
]
|
||||
|
||||
CSV_UPLOAD_BUCKET = _default_s3_credentials('test-notifications-csv-upload')
|
||||
CONTACT_LIST_BUCKET = _default_s3_credentials('test-contact-list')
|
||||
|
||||
# this is overriden in CI
|
||||
SQLALCHEMY_DATABASE_URI = getenv("SQLALCHEMY_DATABASE_TEST_URI")
|
||||
SQLALCHEMY_DATABASE_URI = getenv('SQLALCHEMY_DATABASE_TEST_URI')
|
||||
|
||||
CELERY = {
|
||||
**Config.CELERY,
|
||||
"broker_url": "you-forgot-to-mock-celery-in-your-tests://",
|
||||
'broker_url': 'you-forgot-to-mock-celery-in-your-tests://'
|
||||
}
|
||||
|
||||
TEMPLATE_PREVIEW_API_HOST = "http://localhost:9999"
|
||||
TEMPLATE_PREVIEW_API_HOST = 'http://localhost:9999'
|
||||
|
||||
|
||||
class Production(Config):
|
||||
# buckets
|
||||
CSV_UPLOAD_BUCKET = cloud_config.s3_credentials(
|
||||
f"notify-api-csv-upload-bucket-{Config.NOTIFY_ENVIRONMENT}"
|
||||
)
|
||||
f"notify-api-csv-upload-bucket-{Config.NOTIFY_ENVIRONMENT}")
|
||||
CONTACT_LIST_BUCKET = cloud_config.s3_credentials(
|
||||
f"notify-api-contact-list-bucket-{Config.NOTIFY_ENVIRONMENT}")
|
||||
|
||||
FROM_NUMBER = "Notify.gov"
|
||||
FROM_NUMBER = 'US Notify'
|
||||
CRONITOR_ENABLED = True
|
||||
|
||||
|
||||
@@ -378,10 +352,10 @@ class Demo(Production):
|
||||
|
||||
|
||||
configs = {
|
||||
"development": Development,
|
||||
"test": Test,
|
||||
"staging": Staging,
|
||||
"demo": Demo,
|
||||
"sandbox": Staging,
|
||||
"production": Production,
|
||||
'development': Development,
|
||||
'test': Test,
|
||||
'staging': Staging,
|
||||
'demo': Demo,
|
||||
'sandbox': Staging,
|
||||
'production': Production
|
||||
}
|
||||
|
||||
@@ -1,312 +1,274 @@
|
||||
[
|
||||
{
|
||||
"id": "42a23d19-504e-49bb-a95e-4976baff4757",
|
||||
"name": "Example text message template",
|
||||
"type": "sms",
|
||||
"subject": "",
|
||||
"content": [
|
||||
"Hi, I’m trying out Notify.gov. Today is ((day of week)) and my favorite color is ((color))."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "4f46df42-f795-4cc4-83bb-65ca312f49cc",
|
||||
"name": "Notify invitation email",
|
||||
"type": "email",
|
||||
"subject": "((user_name)) has invited you to collaborate on ((service_name)) on Notify.gov",
|
||||
"content": [
|
||||
"((user_name)) has invited you to collaborate on ((service_name)) on Notify.gov.",
|
||||
"",
|
||||
"",
|
||||
"Notify.gov makes it easy to keep people updated by helping you send text messages.",
|
||||
"",
|
||||
"",
|
||||
"Click this link to create an account on Notify.gov:",
|
||||
"",
|
||||
"[Join Service](((url)))",
|
||||
"",
|
||||
"",
|
||||
"This invitation will stop working at midnight tomorrow. This is to keep ((service_name)) secure."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "36fb0730-6259-4da1-8a80-c8de22ad4246",
|
||||
"name": "Notify SMS verify code",
|
||||
"type": "sms",
|
||||
"subject": "",
|
||||
"content": ["((verify_code)) is your Notify.gov authentication code"]
|
||||
},
|
||||
{
|
||||
"id": "474e9242-823b-4f99-813d-ed392e7f1201",
|
||||
"name": "Notify password reset email",
|
||||
"type": "email",
|
||||
"subject": "Reset your Notify.gov password",
|
||||
"content": [
|
||||
"Hi ((user_name)),",
|
||||
"",
|
||||
"",
|
||||
"We received a request to reset your password on Notify.gov.",
|
||||
"",
|
||||
"",
|
||||
"If you didn’t request this email, you can ignore it – your password has not been changed.",
|
||||
"",
|
||||
"",
|
||||
"To reset your password, click this link:",
|
||||
"",
|
||||
"",
|
||||
"((url))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "299726d2-dba6-42b8-8209-30e1d66ea164",
|
||||
"name": "Notify email verify code",
|
||||
"type": "email",
|
||||
"subject": "Sign in to Notify.gov",
|
||||
"content": [
|
||||
"Hi ((name)),",
|
||||
"",
|
||||
"",
|
||||
"To sign in to Notify.gov please open this link:",
|
||||
"",
|
||||
"",
|
||||
"((url))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ece42649-22a8-4d06-b87f-d52d5d3f0a27",
|
||||
"name": "Notify email verification code",
|
||||
"type": "email",
|
||||
"subject": "Confirm Notify.gov registration",
|
||||
"content": [
|
||||
"Hi ((name)),",
|
||||
"",
|
||||
"",
|
||||
"To complete your registration for Notify.gov please click the link below",
|
||||
"",
|
||||
"",
|
||||
"[Join Service](((url)))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "0880fbb1-a0c6-46f0-9a8e-36c986381ceb",
|
||||
"name": "Your Notify.gov account",
|
||||
"type": "email",
|
||||
"subject": "Your Notify.gov account",
|
||||
"content": [
|
||||
"You already have a Notify.gov account with this email address.",
|
||||
"",
|
||||
"",
|
||||
"Sign in here: ((signin_url))",
|
||||
"",
|
||||
"",
|
||||
"If you’ve forgotten your password, you can reset it here: ((forgot_password_url))",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"If you didn’t try to register for a Notify.gov account recently, please let us know here: ((feedback_url))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eb4d9930-87ab-4aef-9bce-786762687884",
|
||||
"name": "Confirm new email address",
|
||||
"type": "email",
|
||||
"subject": "Confirm your email address for Notify.gov",
|
||||
"content": [
|
||||
"Hi ((name)),",
|
||||
"",
|
||||
"",
|
||||
"Click this link to confirm your new email address:",
|
||||
"",
|
||||
"",
|
||||
"((url))",
|
||||
"",
|
||||
"",
|
||||
"If you didn’t try to change the email address for your Notify.gov account, let us know here:",
|
||||
"",
|
||||
"",
|
||||
"((feedback_url))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "618185c6-3636-49cd-b7d2-6f6f5eb3bdde",
|
||||
"name": "Automated \"You’re now live\" message",
|
||||
"type": "email",
|
||||
"subject": "((service name)) is now live on Notify.gov",
|
||||
"content": [
|
||||
"Hi ((name)),",
|
||||
"",
|
||||
"",
|
||||
"Congratulations, we have received and reviewed your Go Live submission form. ((service name)) is now live on Notify.gov. With your Notify account you now can send messages for the use case outlined in your Go Live submission form.",
|
||||
"",
|
||||
"",
|
||||
"If you have any questions or experience any technical issues, please contact your pilot lead.",
|
||||
"",
|
||||
"",
|
||||
"Thanks",
|
||||
"",
|
||||
"Notify.gov team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "203566f0-d835-47c5-aa06-932439c86573",
|
||||
"name": "Notify organization invitation email",
|
||||
"type": "email",
|
||||
"subject": "((user_name)) has invited you to collaborate on ((organization_name)) on Notify.gov",
|
||||
"content": [
|
||||
"((user_name)) has invited you to collaborate on ((organization_name)) on Notify.gov.",
|
||||
"",
|
||||
"",
|
||||
"Notify.gov makes it easy to keep people updated by helping you send text messages and emails.",
|
||||
"",
|
||||
"",
|
||||
"Open this link to create an account on Notify.gov:",
|
||||
"",
|
||||
"[Join Organization](((url)))",
|
||||
"",
|
||||
"",
|
||||
"This invitation will stop working at midnight tomorrow. This is to keep ((organization_name)) secure."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c73f1d71-4049-46d5-a647-d013bdeca3f0",
|
||||
"name": "Email address changed by service manager",
|
||||
"type": "email",
|
||||
"subject": "Your Notify.gov email address has changed",
|
||||
"content": [
|
||||
"Dear ((name)),",
|
||||
"",
|
||||
"",
|
||||
"((servicemanagername)) changed your Notify account email address to:",
|
||||
"",
|
||||
"",
|
||||
"((email address))",
|
||||
"",
|
||||
"",
|
||||
"You’ll need to use this email address next time you sign in.",
|
||||
"",
|
||||
"",
|
||||
"Thanks",
|
||||
"",
|
||||
"",
|
||||
"Notify.gov team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "8a31520f-4751-4789-8ea1-fe54496725eb",
|
||||
"name": "Phone number changed by service manager",
|
||||
"type": "sms",
|
||||
"subject": "",
|
||||
"content": [
|
||||
"Your mobile number was changed by ((servicemanagername)). Next time you sign in, your Notify.gov authentication code will be sent to this phone."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a42f1d17-9404-46d5-a647-d013bdfca3e1",
|
||||
"name": "Verify email reply-to address for a service",
|
||||
"type": "email",
|
||||
"subject": "Your Notify.gov reply-to email address",
|
||||
"content": [
|
||||
"Hi,",
|
||||
"",
|
||||
"",
|
||||
"This address has been provided as a reply-to email address for a Notify.gov account.",
|
||||
"",
|
||||
"Any replies from users to emails they receive through Notify.gov will come back to this email address.",
|
||||
"",
|
||||
"",
|
||||
"This is just a quick check to make sure the address is valid.",
|
||||
"",
|
||||
"",
|
||||
"No need to reply.",
|
||||
"",
|
||||
"",
|
||||
"Thanks",
|
||||
"",
|
||||
"",
|
||||
"Notify.gov team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "4fd2e43c-309b-4e50-8fb8-1955852d9d71",
|
||||
"name": "MOU Signed By Receipt",
|
||||
"type": "email",
|
||||
"subject": "You’ve accepted the Notify.gov data sharing and financial agreement",
|
||||
"content": [
|
||||
"Hi ((signed_by_name)),",
|
||||
"",
|
||||
"((org_name)) has accepted the Notify.gov data sharing and financial agreement. ",
|
||||
"",
|
||||
"If you need another copy of the agreement you can download it here: ((mou_link))",
|
||||
"",
|
||||
"",
|
||||
"Thanks,",
|
||||
"Notify.gov team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c20206d5-bf03-4002-9a90-37d5032d9e84",
|
||||
"name": "MOU Signed On Behalf Of Receipt - Signed by",
|
||||
"type": "email",
|
||||
"subject": "You’ve accepted the Notify.gov data sharing and financial agreement",
|
||||
"content": [
|
||||
"Hi ((signed_by_name)),",
|
||||
"",
|
||||
"((org_name)) has accepted the Notify.gov data sharing and financial agreement. We’ve emailed ((on_behalf_of_name)) to let them know too.",
|
||||
"",
|
||||
"If you need another copy of the agreement you can download it here: ((mou_link))",
|
||||
"",
|
||||
"",
|
||||
"Thanks,",
|
||||
"Notify.gov team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "522b6657-5ca5-4368-a294-6b527703bd0b",
|
||||
"name": "MOU Signed On Behalf Of Receipt - On Behalf Of",
|
||||
"type": "email",
|
||||
"subject": "((org_name)) has accepted the Notify.gov data sharing and financial agreement",
|
||||
"content": [
|
||||
"Hi ((on_behalf_of_name)),",
|
||||
"",
|
||||
"((signed_by_name)) has accepted the Notify.gov data sharing and financial agreement on your behalf, for ((org_name)).",
|
||||
"",
|
||||
"Notify.gov lets teams in the public sector send text messages and emails. It’s built and run by a team in the TTS Public Benefits Studio (part of GSA).",
|
||||
"",
|
||||
"If you need another copy of the agreement you can download it here: ((mou_link))",
|
||||
"",
|
||||
"",
|
||||
"Thanks,",
|
||||
"Notify.gov team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d0e66c4c-0c50-43f0-94f5-f85b613202d4",
|
||||
"name": "MOU Signed Notify Team Alert",
|
||||
"type": "email",
|
||||
"subject": "Someone signed an MOU for an org on Notify",
|
||||
"content": [
|
||||
"What’s up Notifiers,",
|
||||
"",
|
||||
"((signed_by_name)) just accepted the data sharing and financial agreement for ((org_name)).",
|
||||
"",
|
||||
"See how ((org_name)) is using Notify here: ((org_dashboard_link))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "11fad854-fd38-4a7c-bd17-805fb13dfc12",
|
||||
"name": "Notify daily letter volumes",
|
||||
"type": "email",
|
||||
"subject": "Notify letter volume for ((date)): ((total_volume)) letters, ((total_sheets)) sheets",
|
||||
"content": [
|
||||
"((total_volume)) letters (((total_sheets)) sheets) sent via Notify are coming in today’s batch. These include: ",
|
||||
"",
|
||||
"((first_class_volume)) first class letters (((first_class_sheets)) sheets).",
|
||||
"((second_class_volume)) second class letters (((second_class_sheets)) sheets).",
|
||||
"((international_volume)) international letters (((international_sheets)) sheets).",
|
||||
"",
|
||||
"Thanks",
|
||||
"",
|
||||
"Notify.gov team"
|
||||
]
|
||||
}
|
||||
{
|
||||
"id": "42a23d19-504e-49bb-a95e-4976baff4757",
|
||||
"name": "Example text message template",
|
||||
"type": "sms",
|
||||
"subject": "",
|
||||
"content": ["Hi, I’m trying out U.S. Notify. Today is ((day of week)) and my favorite color is ((color))."]
|
||||
},
|
||||
{
|
||||
"id": "4f46df42-f795-4cc4-83bb-65ca312f49cc",
|
||||
"name": "Notify invitation email",
|
||||
"type": "email",
|
||||
"subject": "((user_name)) has invited you to collaborate on ((service_name)) on U.S. Notify",
|
||||
"content": ["((user_name)) has invited you to collaborate on ((service_name)) on U.S. Notify.",
|
||||
"",
|
||||
"",
|
||||
"U.S. Notify makes it easy to keep people updated by helping you send text messages and emails.",
|
||||
"",
|
||||
"",
|
||||
"Click this link to create an account on U.S. Notify:",
|
||||
"",
|
||||
"((url))",
|
||||
"",
|
||||
"",
|
||||
"This invitation will stop working at midnight tomorrow. This is to keep ((service_name)) secure."]
|
||||
},
|
||||
{
|
||||
"id": "36fb0730-6259-4da1-8a80-c8de22ad4246",
|
||||
"name": "Notify SMS verify code",
|
||||
"type": "sms",
|
||||
"subject": "",
|
||||
"content": ["((verify_code)) is your U.S. Notify authentication code"]
|
||||
},
|
||||
{
|
||||
"id": "474e9242-823b-4f99-813d-ed392e7f1201",
|
||||
"name": "Notify password reset email",
|
||||
"type": "email",
|
||||
"subject": "Reset your U.S. Notify password",
|
||||
"content": ["Hi ((user_name)),",
|
||||
"",
|
||||
"",
|
||||
"We received a request to reset your password on U.S. Notify.",
|
||||
"",
|
||||
"",
|
||||
"If you didn’t request this email, you can ignore it – your password has not been changed.",
|
||||
"",
|
||||
"",
|
||||
"To reset your password, click this link:","","","((url))"]
|
||||
},
|
||||
{
|
||||
"id": "299726d2-dba6-42b8-8209-30e1d66ea164",
|
||||
"name": "Notify email verify code",
|
||||
"type": "email",
|
||||
"subject": "Sign in to U.S. Notify",
|
||||
"content": ["Hi ((name)),",
|
||||
"",
|
||||
"",
|
||||
"To sign in to U.S. Notify please open this link:",
|
||||
"",
|
||||
"",
|
||||
"((url))"]
|
||||
},
|
||||
{
|
||||
"id": "ece42649-22a8-4d06-b87f-d52d5d3f0a27",
|
||||
"name": "Notify email verification code",
|
||||
"type": "email",
|
||||
"subject": "Confirm U.S. Notify registration",
|
||||
"content": ["Hi ((name)),",
|
||||
"",
|
||||
"",
|
||||
"To complete your registration for U.S. Notify please click the link below",
|
||||
"",
|
||||
"",
|
||||
"((url))"]
|
||||
},
|
||||
{
|
||||
"id": "0880fbb1-a0c6-46f0-9a8e-36c986381ceb",
|
||||
"name": "Your U.S. Notify account",
|
||||
"type": "email",
|
||||
"subject": "Your U.S. Notify account",
|
||||
"content": ["You already have a U.S. Notify account with this email address.",
|
||||
"",
|
||||
"",
|
||||
"Sign in here: ((signin_url))",
|
||||
"",
|
||||
"",
|
||||
"If you’ve forgotten your password, you can reset it here: ((forgot_password_url))",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"If you didn’t try to register for a U.S. Notify account recently, please let us know here: ((feedback_url))"]
|
||||
},
|
||||
{
|
||||
"id": "eb4d9930-87ab-4aef-9bce-786762687884",
|
||||
"name": "Confirm new email address",
|
||||
"type": "email",
|
||||
"subject": "Confirm your email address for U.S. Notify",
|
||||
"content": ["Hi ((name)),","","","Click this link to confirm your new email address:",
|
||||
"",
|
||||
"",
|
||||
"((url))",
|
||||
"",
|
||||
"",
|
||||
"If you didn’t try to change the email address for your U.S. Notify account, let us know here:",
|
||||
"",
|
||||
"",
|
||||
"((feedback_url))"]
|
||||
},
|
||||
{
|
||||
"id": "618185c6-3636-49cd-b7d2-6f6f5eb3bdde",
|
||||
"name": "Automated \"You’re now live\" message",
|
||||
"type": "email",
|
||||
"subject": "((service name)) is now live on U.S. Notify",
|
||||
"content": ["Hi ((name)),",
|
||||
"",
|
||||
"",
|
||||
"((service name)) is now live on U.S. Notify.",
|
||||
"",
|
||||
"",
|
||||
"You can send up to ((message limit)) messages per day.",
|
||||
"",
|
||||
"",
|
||||
"As a live service, you’ll need to know who to contact if you have a question, or something goes wrong.",
|
||||
"",
|
||||
"",
|
||||
"If our system status page shows a problem, then we’ve been alerted and are working on it – you don’t need to contact us.",
|
||||
"",
|
||||
"",
|
||||
"#Problems or questions out of hours",
|
||||
"",
|
||||
"",
|
||||
"We offer out of hours support for emergencies.",
|
||||
"",
|
||||
"",
|
||||
"It’s only an emergency if:",
|
||||
"",
|
||||
"* no one in your team can log in",
|
||||
"",
|
||||
"* a ‘technical difficulties’ error appears when you try to upload a file",
|
||||
"",
|
||||
"* a 500 response code appears when you try to send messages using the API",
|
||||
"",
|
||||
"",
|
||||
"If you have one of these emergencies, email details to:",
|
||||
"",
|
||||
"notify-support@gsa.gov",
|
||||
"",
|
||||
"",
|
||||
"^Only use this email address for out of hours emergencies. Don’t share this address with people outside of your team.",
|
||||
"",
|
||||
"",
|
||||
"We’ll get back to you within 30 minutes and give you hourly updates until the problem’s fixed.",
|
||||
"",
|
||||
"",
|
||||
"For non-emergency problems or questions, use our support page and we’ll reply in office hours.",
|
||||
"",
|
||||
"",
|
||||
"Thanks",
|
||||
"",
|
||||
"U.S. Notify team"]
|
||||
},
|
||||
{
|
||||
"id": "203566f0-d835-47c5-aa06-932439c86573",
|
||||
"name": "Notify organization invitation email",
|
||||
"type": "email",
|
||||
"subject": "((user_name)) has invited you to collaborate on ((organisation_name)) on U.S. Notify",
|
||||
"content": ["((user_name)) has invited you to collaborate on ((organisation_name)) on U.S. Notify.","","","U.S. Notify makes it easy to keep people updated by helping you send text messages and emails.","","","Open this link to create an account on U.S. Notify:","","((url))","","","This invitation will stop working at midnight tomorrow. This is to keep ((organisation_name)) secure."]
|
||||
},
|
||||
{
|
||||
"id": "c73f1d71-4049-46d5-a647-d013bdeca3f0",
|
||||
"name": "Email address changed by service manager",
|
||||
"type": "email",
|
||||
"subject": "Your U.S. Notify email address has changed",
|
||||
"content": ["Dear ((name)),","","","((servicemanagername)) changed your Notify account email address to:","","","((email address))","","","You’ll need to use this email address next time you sign in.","","","Thanks","","","U.S. Notify team"]
|
||||
},
|
||||
{
|
||||
"id": "8a31520f-4751-4789-8ea1-fe54496725eb",
|
||||
"name": "Phone number changed by service manager",
|
||||
"type": "sms",
|
||||
"subject": "",
|
||||
"content": ["Your mobile number was changed by ((servicemanagername)). Next time you sign in, your U.S. Notify authentication code will be sent to this phone."]
|
||||
},
|
||||
{
|
||||
"id": "a42f1d17-9404-46d5-a647-d013bdfca3e1",
|
||||
"name": "Verify email reply-to address for a service",
|
||||
"type": "email",
|
||||
"subject": "Your U.S. Notify reply-to email address",
|
||||
"content": ["Hi,","","","This address has been provided as a reply-to email address for a U.S. Notify account.","","Any replies from users to emails they receive through U.S. Notify will come back to this email address.","","","This is just a quick check to make sure the address is valid.","","","No need to reply.","","","Thanks","","","U.S. Notify team"]
|
||||
},
|
||||
{
|
||||
"id": "4fd2e43c-309b-4e50-8fb8-1955852d9d71",
|
||||
"name": "MOU Signed By Receipt",
|
||||
"type": "email",
|
||||
"subject": "You’ve accepted the U.S. Notify data sharing and financial agreement",
|
||||
"content": [
|
||||
"Hi ((signed_by_name)),",
|
||||
"",
|
||||
"((org_name)) has accepted the U.S. Notify data sharing and financial agreement. ",
|
||||
"",
|
||||
"If you need another copy of the agreement you can download it here: ((mou_link))",
|
||||
"",
|
||||
"",
|
||||
"Thanks,",
|
||||
"U.S. Notify team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c20206d5-bf03-4002-9a90-37d5032d9e84",
|
||||
"name": "MOU Signed On Behalf Of Receipt - Signed by",
|
||||
"type": "email",
|
||||
"subject": "You’ve accepted the U.S. Notify data sharing and financial agreement",
|
||||
"content": [
|
||||
"Hi ((signed_by_name)),",
|
||||
"",
|
||||
"((org_name)) has accepted the U.S. Notify data sharing and financial agreement. We’ve emailed ((on_behalf_of_name)) to let them know too.",
|
||||
"",
|
||||
"If you need another copy of the agreement you can download it here: ((mou_link))",
|
||||
"",
|
||||
"",
|
||||
"Thanks,",
|
||||
"U.S. Notify team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "522b6657-5ca5-4368-a294-6b527703bd0b",
|
||||
"name": "MOU Signed On Behalf Of Receipt - On Behalf Of",
|
||||
"type": "email",
|
||||
"subject": "((org_name)) has accepted the U.S. Notify data sharing and financial agreement",
|
||||
"content": [
|
||||
"Hi ((on_behalf_of_name)),",
|
||||
"",
|
||||
"((signed_by_name)) has accepted the U.S. Notify data sharing and financial agreement on your behalf, for ((org_name)).",
|
||||
"",
|
||||
"U.S. Notify lets teams in the public sector send text messages and emails. It’s built and run by a team in the TTS Public Benefits Studio (part of GSA).",
|
||||
"",
|
||||
"If you need another copy of the agreement you can download it here: ((mou_link))",
|
||||
"",
|
||||
"",
|
||||
"Thanks,",
|
||||
"U.S. Notify team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d0e66c4c-0c50-43f0-94f5-f85b613202d4",
|
||||
"name": "MOU Signed Notify Team Alert",
|
||||
"type": "email",
|
||||
"subject": "Someone signed an MOU for an org on Notify",
|
||||
"content": [
|
||||
"What’s up Notifiers,",
|
||||
"",
|
||||
"((signed_by_name)) just accepted the data sharing and financial agreement for ((org_name)).",
|
||||
"",
|
||||
"See how ((org_name)) is using Notify here: ((org_dashboard_link))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "11fad854-fd38-4a7c-bd17-805fb13dfc12",
|
||||
"name": "Notify daily letter volumes",
|
||||
"type": "email",
|
||||
"subject": "Notify letter volume for ((date)): ((total_volume)) letters, ((total_sheets)) sheets",
|
||||
"content": [
|
||||
"((total_volume)) letters (((total_sheets)) sheets) sent via Notify are coming in today’s batch. These include: ",
|
||||
"",
|
||||
"((first_class_volume)) first class letters (((first_class_sheets)) sheets).",
|
||||
"((second_class_volume)) second class letters (((second_class_sheets)) sheets).",
|
||||
"((international_volume)) international letters (((international_sheets)) sheets).",
|
||||
"",
|
||||
"Thanks",
|
||||
"",
|
||||
"U.S. Notify team"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,54 +7,48 @@ from flask import current_app
|
||||
def cronitor(task_name):
|
||||
def decorator(func):
|
||||
def ping_cronitor(command):
|
||||
if not current_app.config["CRONITOR_ENABLED"]:
|
||||
if not current_app.config['CRONITOR_ENABLED']:
|
||||
return
|
||||
|
||||
# it's useful to have a log that a periodic task has started in case it
|
||||
# get stuck without generating any other logs - we know it got this far
|
||||
current_app.logger.info(f"Pinging Cronitor for Celery task {task_name}")
|
||||
current_app.logger.info(f'Pinging Cronitor for Celery task {task_name}')
|
||||
|
||||
task_slug = current_app.config["CRONITOR_KEYS"].get(task_name)
|
||||
task_slug = current_app.config['CRONITOR_KEYS'].get(task_name)
|
||||
if not task_slug:
|
||||
current_app.logger.error(
|
||||
"Cronitor enabled but task_name {} not found in environment".format(
|
||||
task_name
|
||||
)
|
||||
'Cronitor enabled but task_name {} not found in environment'.format(task_name)
|
||||
)
|
||||
return
|
||||
|
||||
if command not in {"run", "complete", "fail"}:
|
||||
raise ValueError(
|
||||
"command {} not a valid cronitor command".format(command)
|
||||
)
|
||||
if command not in {'run', 'complete', 'fail'}:
|
||||
raise ValueError('command {} not a valid cronitor command'.format(command))
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://cronitor.link/{}/{}".format(task_slug, command),
|
||||
'https://cronitor.link/{}/{}'.format(task_slug, command),
|
||||
# cronitor limits msg to 1000 characters
|
||||
params={
|
||||
"host": current_app.config["API_HOST_NAME"],
|
||||
},
|
||||
'host': current_app.config['API_HOST_NAME'],
|
||||
}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
current_app.logger.warning(
|
||||
"Cronitor API failed for task {} due to {}".format(
|
||||
task_name, repr(e)
|
||||
)
|
||||
)
|
||||
current_app.logger.warning('Cronitor API failed for task {} due to {}'.format(
|
||||
task_name,
|
||||
repr(e)
|
||||
))
|
||||
|
||||
@wraps(func)
|
||||
def inner_decorator(*args, **kwargs):
|
||||
ping_cronitor("run")
|
||||
status = "fail"
|
||||
ping_cronitor('run')
|
||||
status = 'fail'
|
||||
try:
|
||||
ret = func(*args, **kwargs)
|
||||
status = "complete"
|
||||
status = 'complete'
|
||||
return ret
|
||||
finally:
|
||||
ping_cronitor(status)
|
||||
|
||||
return inner_decorator
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -9,6 +9,7 @@ class DAOException(SQLAlchemyError):
|
||||
|
||||
|
||||
class DAOClass(object):
|
||||
|
||||
class Meta:
|
||||
model = None
|
||||
|
||||
@@ -17,6 +18,13 @@ class DAOClass(object):
|
||||
if _commit:
|
||||
db.session.commit()
|
||||
|
||||
def update_instance(self, inst, update_dict, _commit=True):
|
||||
# Make sure the id is not included in the update_dict
|
||||
update_dict.pop('id')
|
||||
self.Meta.model.query.filter_by(id=inst.id).update(update_dict)
|
||||
if _commit:
|
||||
db.session.commit()
|
||||
|
||||
def delete_instance(self, inst, _commit=True):
|
||||
db.session.delete(inst)
|
||||
if _commit:
|
||||
|
||||
@@ -2,106 +2,91 @@ from flask import current_app
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.dao.date_util import get_current_calendar_year_start_year
|
||||
from app.enums import OrganizationType
|
||||
from app.dao.date_util import get_current_financial_year_start_year
|
||||
from app.models import AnnualBilling
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_create_or_update_annual_billing_for_year(
|
||||
service_id, free_sms_fragment_limit, financial_year_start
|
||||
):
|
||||
def dao_create_or_update_annual_billing_for_year(service_id, free_sms_fragment_limit, financial_year_start):
|
||||
result = dao_get_free_sms_fragment_limit_for_year(service_id, financial_year_start)
|
||||
|
||||
if result:
|
||||
result.free_sms_fragment_limit = free_sms_fragment_limit
|
||||
else:
|
||||
result = AnnualBilling(
|
||||
service_id=service_id,
|
||||
financial_year_start=financial_year_start,
|
||||
free_sms_fragment_limit=free_sms_fragment_limit,
|
||||
)
|
||||
result = AnnualBilling(service_id=service_id, financial_year_start=financial_year_start,
|
||||
free_sms_fragment_limit=free_sms_fragment_limit)
|
||||
db.session.add(result)
|
||||
return result
|
||||
|
||||
|
||||
def dao_get_annual_billing(service_id):
|
||||
return (
|
||||
AnnualBilling.query.filter_by(
|
||||
service_id=service_id,
|
||||
)
|
||||
.order_by(AnnualBilling.financial_year_start)
|
||||
.all()
|
||||
)
|
||||
return AnnualBilling.query.filter_by(
|
||||
service_id=service_id,
|
||||
).order_by(AnnualBilling.financial_year_start).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_update_annual_billing_for_future_years(
|
||||
service_id, free_sms_fragment_limit, financial_year_start
|
||||
):
|
||||
def dao_update_annual_billing_for_future_years(service_id, free_sms_fragment_limit, financial_year_start):
|
||||
AnnualBilling.query.filter(
|
||||
AnnualBilling.service_id == service_id,
|
||||
AnnualBilling.financial_year_start > financial_year_start,
|
||||
).update({"free_sms_fragment_limit": free_sms_fragment_limit})
|
||||
AnnualBilling.financial_year_start > financial_year_start
|
||||
).update(
|
||||
{'free_sms_fragment_limit': free_sms_fragment_limit}
|
||||
)
|
||||
|
||||
|
||||
def dao_get_free_sms_fragment_limit_for_year(service_id, financial_year_start=None):
|
||||
|
||||
if not financial_year_start:
|
||||
financial_year_start = get_current_calendar_year_start_year()
|
||||
financial_year_start = get_current_financial_year_start_year()
|
||||
|
||||
return AnnualBilling.query.filter_by(
|
||||
service_id=service_id, financial_year_start=financial_year_start
|
||||
service_id=service_id,
|
||||
financial_year_start=financial_year_start
|
||||
).first()
|
||||
|
||||
|
||||
def dao_get_all_free_sms_fragment_limit(service_id):
|
||||
return (
|
||||
AnnualBilling.query.filter_by(
|
||||
service_id=service_id,
|
||||
)
|
||||
.order_by(AnnualBilling.financial_year_start)
|
||||
.all()
|
||||
)
|
||||
|
||||
return AnnualBilling.query.filter_by(
|
||||
service_id=service_id,
|
||||
).order_by(AnnualBilling.financial_year_start).all()
|
||||
|
||||
|
||||
def set_default_free_allowance_for_service(service, year_start=None):
|
||||
default_free_sms_fragment_limits = {
|
||||
OrganizationType.FEDERAL: {
|
||||
'federal': {
|
||||
2020: 250_000,
|
||||
2021: 150_000,
|
||||
2022: 40_000,
|
||||
},
|
||||
OrganizationType.STATE: {
|
||||
'state': {
|
||||
2020: 250_000,
|
||||
2021: 150_000,
|
||||
2022: 40_000,
|
||||
},
|
||||
OrganizationType.OTHER: {
|
||||
'other': {
|
||||
2020: 250_000,
|
||||
2021: 150_000,
|
||||
2022: 40_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
if not year_start:
|
||||
year_start = get_current_calendar_year_start_year()
|
||||
year_start = get_current_financial_year_start_year()
|
||||
# handle cases where the year is less than 2020 or greater than 2021
|
||||
if year_start < 2020:
|
||||
year_start = 2020
|
||||
if year_start > 2022:
|
||||
year_start = 2022
|
||||
if service.organization_type:
|
||||
free_allowance = default_free_sms_fragment_limits[service.organization_type][
|
||||
year_start
|
||||
]
|
||||
if service.organisation_type:
|
||||
free_allowance = default_free_sms_fragment_limits[service.organisation_type][year_start]
|
||||
else:
|
||||
current_app.logger.info(
|
||||
f"no organization type for service {service.id}. Using other default of "
|
||||
f"{default_free_sms_fragment_limits['other'][year_start]}"
|
||||
)
|
||||
free_allowance = default_free_sms_fragment_limits[OrganizationType.OTHER][
|
||||
year_start
|
||||
]
|
||||
current_app.logger.info(f"no organisation type for service {service.id}. Using other default of "
|
||||
f"{default_free_sms_fragment_limits['other'][year_start]}")
|
||||
free_allowance = default_free_sms_fragment_limits['other'][year_start]
|
||||
|
||||
return dao_create_or_update_annual_billing_for_year(
|
||||
service.id, free_allowance, year_start
|
||||
service.id,
|
||||
free_allowance,
|
||||
year_start
|
||||
)
|
||||
|
||||
@@ -12,9 +12,7 @@ from app.models import ApiKey
|
||||
@version_class(ApiKey)
|
||||
def save_model_api_key(api_key):
|
||||
if not api_key.id:
|
||||
api_key.id = (
|
||||
uuid.uuid4()
|
||||
) # must be set now so version history model can use same id
|
||||
api_key.id = uuid.uuid4() # must be set now so version history model can use same id
|
||||
api_key.secret = uuid.uuid4()
|
||||
db.session.add(api_key)
|
||||
|
||||
@@ -29,16 +27,11 @@ def expire_api_key(service_id, api_key_id):
|
||||
|
||||
def get_model_api_keys(service_id, id=None):
|
||||
if id:
|
||||
return ApiKey.query.filter_by(
|
||||
id=id, service_id=service_id, expiry_date=None
|
||||
).one()
|
||||
return ApiKey.query.filter_by(id=id, service_id=service_id, expiry_date=None).one()
|
||||
seven_days_ago = datetime.utcnow() - timedelta(days=7)
|
||||
return ApiKey.query.filter(
|
||||
or_(
|
||||
ApiKey.expiry_date == None, # noqa
|
||||
func.date(ApiKey.expiry_date) > seven_days_ago, # noqa
|
||||
),
|
||||
ApiKey.service_id == service_id,
|
||||
or_(ApiKey.expiry_date == None, func.date(ApiKey.expiry_date) > seven_days_ago), # noqa
|
||||
ApiKey.service_id == service_id
|
||||
).all()
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import desc
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import Complaint
|
||||
from app.utils import get_midnight_in_utc
|
||||
from app.utils import get_local_midnight_in_utc
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -15,23 +15,20 @@ def save_complaint(complaint):
|
||||
|
||||
|
||||
def fetch_paginated_complaints(page=1):
|
||||
return Complaint.query.order_by(desc(Complaint.created_at)).paginate(
|
||||
page=page, per_page=current_app.config["PAGE_SIZE"]
|
||||
return Complaint.query.order_by(
|
||||
desc(Complaint.created_at)
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=current_app.config['PAGE_SIZE']
|
||||
)
|
||||
|
||||
|
||||
def fetch_complaints_by_service(service_id):
|
||||
return (
|
||||
Complaint.query.filter_by(service_id=service_id)
|
||||
.order_by(desc(Complaint.created_at))
|
||||
.all()
|
||||
)
|
||||
return Complaint.query.filter_by(service_id=service_id).order_by(desc(Complaint.created_at)).all()
|
||||
|
||||
|
||||
def fetch_count_of_complaints(start_date, end_date):
|
||||
start_date = get_midnight_in_utc(start_date)
|
||||
end_date = get_midnight_in_utc(end_date + timedelta(days=1))
|
||||
start_date = get_local_midnight_in_utc(start_date)
|
||||
end_date = get_local_midnight_in_utc(end_date + timedelta(days=1))
|
||||
|
||||
return Complaint.query.filter(
|
||||
Complaint.created_at >= start_date, Complaint.created_at < end_date
|
||||
).count()
|
||||
return Complaint.query.filter(Complaint.created_at >= start_date, Complaint.created_at < end_date).count()
|
||||
|
||||
@@ -19,7 +19,6 @@ def autocommit(func):
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
raise
|
||||
|
||||
return commit_or_rollback
|
||||
|
||||
|
||||
@@ -37,7 +36,8 @@ def transaction():
|
||||
raise
|
||||
|
||||
|
||||
class VersionOptions:
|
||||
class VersionOptions():
|
||||
|
||||
def __init__(self, model_class, history_class=None, must_write_history=True):
|
||||
self.model_class = model_class
|
||||
self.history_class = history_class
|
||||
@@ -45,34 +45,38 @@ class VersionOptions:
|
||||
|
||||
|
||||
def version_class(*version_options):
|
||||
|
||||
if len(version_options) == 1 and not isinstance(version_options[0], VersionOptions):
|
||||
version_options = (VersionOptions(version_options[0]),)
|
||||
|
||||
def versioned(func):
|
||||
@wraps(func)
|
||||
def record_version(*args, **kwargs):
|
||||
|
||||
func(*args, **kwargs)
|
||||
|
||||
session_objects = []
|
||||
|
||||
for version_option in version_options:
|
||||
tmp_session_objects = [
|
||||
(session_object, version_option.history_class)
|
||||
(
|
||||
session_object, version_option.history_class
|
||||
)
|
||||
for session_object in itertools.chain(
|
||||
db.session.new, db.session.dirty
|
||||
)
|
||||
if isinstance(session_object, version_option.model_class)
|
||||
if isinstance(
|
||||
session_object, version_option.model_class
|
||||
)
|
||||
]
|
||||
|
||||
if tmp_session_objects == [] and version_option.must_write_history:
|
||||
raise RuntimeError(
|
||||
(
|
||||
"Can't record history for {} "
|
||||
"(something in your code has casued the database to "
|
||||
"flush the session early so there's nothing to "
|
||||
"copy into the history table)"
|
||||
).format(version_option.model_class.__name__)
|
||||
)
|
||||
raise RuntimeError((
|
||||
'Can\'t record history for {} '
|
||||
'(something in your code has casued the database to '
|
||||
'flush the session early so there\'s nothing to '
|
||||
'copy into the history table)'
|
||||
).format(version_option.model_class.__name__))
|
||||
|
||||
session_objects += tmp_session_objects
|
||||
|
||||
@@ -82,7 +86,6 @@ def version_class(*version_options):
|
||||
)
|
||||
|
||||
return record_version
|
||||
|
||||
return versioned
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
from datetime import date, datetime, time, timedelta
|
||||
|
||||
import pytz
|
||||
from notifications_utils.timezones import (
|
||||
convert_local_timezone_to_utc,
|
||||
convert_utc_to_local_timezone,
|
||||
local_timezone,
|
||||
)
|
||||
|
||||
|
||||
def get_months_for_financial_year(year):
|
||||
return [
|
||||
month for month in (get_months_for_year(1, 13, year)) if month < datetime.now()
|
||||
convert_local_timezone_to_utc(month) for month in (
|
||||
get_months_for_year(4, 13, year)
|
||||
+ get_months_for_year(1, 4, year + 1)
|
||||
)
|
||||
if convert_local_timezone_to_utc(month) < datetime.now()
|
||||
]
|
||||
|
||||
|
||||
@@ -11,56 +22,66 @@ def get_months_for_year(start, end, year):
|
||||
return [datetime(year, month, 1) for month in range(start, end)]
|
||||
|
||||
|
||||
def get_calendar_year(year):
|
||||
return get_new_years(year), get_new_years(year + 1) - timedelta(microseconds=1)
|
||||
def get_financial_year(year):
|
||||
return get_april_fools(year), get_april_fools(year + 1) - timedelta(microseconds=1)
|
||||
|
||||
|
||||
def get_calendar_year_dates(year):
|
||||
year_start_datetime, year_end_datetime = get_calendar_year(year)
|
||||
def get_financial_year_dates(year):
|
||||
year_start_datetime, year_end_datetime = get_financial_year(year)
|
||||
|
||||
return (year_start_datetime.date(), year_end_datetime.date())
|
||||
return (
|
||||
convert_utc_to_local_timezone(year_start_datetime).date(),
|
||||
convert_utc_to_local_timezone(year_end_datetime).date()
|
||||
)
|
||||
|
||||
|
||||
def get_current_calendar_year():
|
||||
def get_current_financial_year():
|
||||
now = datetime.utcnow()
|
||||
current_year = int(now.strftime("%Y"))
|
||||
year = current_year
|
||||
return get_calendar_year(year)
|
||||
current_month = int(now.strftime('%-m'))
|
||||
current_year = int(now.strftime('%Y'))
|
||||
year = current_year if current_month > 3 else current_year - 1
|
||||
return get_financial_year(year)
|
||||
|
||||
|
||||
def get_new_years(year):
|
||||
return datetime(year, 1, 1, 0, 0, 0)
|
||||
def get_april_fools(year):
|
||||
"""
|
||||
This function converts the start of the financial year April 1, 00:00 as BST (British Standard Time) to UTC,
|
||||
the tzinfo is lastly removed from the datetime because the database stores the timestamps without timezone.
|
||||
:param year: the year to calculate the April 1, 00:00 BST for
|
||||
:return: the datetime of April 1 for the given year, for example 2016 = 2016-03-31 23:00:00
|
||||
"""
|
||||
return local_timezone.localize(
|
||||
datetime(year, 4, 1, 0, 0, 0)).astimezone(pytz.UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def get_month_start_and_end_date_in_utc(month_year):
|
||||
"""
|
||||
This function return the start and date of the month_year as UTC,
|
||||
:param month_year: the datetime to calculate the start and end date for that month
|
||||
:return: start_date, end_date, month
|
||||
This function return the start and date of the month_year as UTC,
|
||||
:param month_year: the datetime to calculate the start and end date for that month
|
||||
:return: start_date, end_date, month
|
||||
"""
|
||||
import calendar
|
||||
|
||||
_, num_days = calendar.monthrange(month_year.year, month_year.month)
|
||||
first_day = datetime(month_year.year, month_year.month, 1, 0, 0, 0)
|
||||
last_day = datetime(month_year.year, month_year.month, num_days, 23, 59, 59, 99999)
|
||||
return first_day, last_day
|
||||
return convert_local_timezone_to_utc(first_day), convert_local_timezone_to_utc(last_day)
|
||||
|
||||
|
||||
def get_current_calendar_year_start_year():
|
||||
def get_current_financial_year_start_year():
|
||||
now = datetime.now()
|
||||
financial_year_start = now.year
|
||||
start_date, end_date = get_calendar_year(now.year)
|
||||
start_date, end_date = get_financial_year(now.year)
|
||||
if now < start_date:
|
||||
financial_year_start = financial_year_start - 1
|
||||
return financial_year_start
|
||||
|
||||
|
||||
def get_calendar_year_for_datetime(start_date):
|
||||
if isinstance(start_date, date):
|
||||
def get_financial_year_for_datetime(start_date):
|
||||
if type(start_date) == date:
|
||||
start_date = datetime.combine(start_date, time.min)
|
||||
|
||||
year = int(start_date.strftime("%Y"))
|
||||
if start_date < get_new_years(year):
|
||||
year = int(start_date.strftime('%Y'))
|
||||
if start_date < get_april_fools(year):
|
||||
return year - 1
|
||||
else:
|
||||
return year
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,20 @@ from sqlalchemy.types import DateTime, Integer
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.enums import KeyType, NotificationStatus, NotificationType
|
||||
from app.models import (
|
||||
KEY_TYPE_NORMAL,
|
||||
KEY_TYPE_TEAM,
|
||||
KEY_TYPE_TEST,
|
||||
NOTIFICATION_CANCELLED,
|
||||
NOTIFICATION_CREATED,
|
||||
NOTIFICATION_DELIVERED,
|
||||
NOTIFICATION_FAILED,
|
||||
NOTIFICATION_PENDING,
|
||||
NOTIFICATION_PERMANENT_FAILURE,
|
||||
NOTIFICATION_SENDING,
|
||||
NOTIFICATION_SENT,
|
||||
NOTIFICATION_TECHNICAL_FAILURE,
|
||||
NOTIFICATION_TEMPORARY_FAILURE,
|
||||
FactNotificationStatus,
|
||||
Notification,
|
||||
NotificationAllTimeView,
|
||||
@@ -16,16 +28,16 @@ from app.models import (
|
||||
Template,
|
||||
)
|
||||
from app.utils import (
|
||||
get_midnight_in_utc,
|
||||
get_month_from_utc_column,
|
||||
get_local_midnight_in_utc,
|
||||
get_local_month_from_utc_column,
|
||||
midnight_n_days_ago,
|
||||
)
|
||||
|
||||
|
||||
@autocommit
|
||||
def update_fact_notification_status(process_day, notification_type, service_id):
|
||||
start_date = get_midnight_in_utc(process_day)
|
||||
end_date = get_midnight_in_utc(process_day + timedelta(days=1))
|
||||
start_date = get_local_midnight_in_utc(process_day)
|
||||
end_date = get_local_midnight_in_utc(process_day + timedelta(days=1))
|
||||
|
||||
# delete any existing rows in case some no longer exist e.g. if all messages are sent
|
||||
FactNotificationStatus.query.filter(
|
||||
@@ -34,33 +46,27 @@ def update_fact_notification_status(process_day, notification_type, service_id):
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
).delete()
|
||||
|
||||
query = (
|
||||
db.session.query(
|
||||
literal(process_day).label("process_day"),
|
||||
NotificationAllTimeView.template_id,
|
||||
literal(service_id).label("service_id"),
|
||||
func.coalesce(
|
||||
NotificationAllTimeView.job_id, "00000000-0000-0000-0000-000000000000"
|
||||
).label("job_id"),
|
||||
literal(notification_type).label("notification_type"),
|
||||
NotificationAllTimeView.key_type,
|
||||
NotificationAllTimeView.status,
|
||||
func.count().label("notification_count"),
|
||||
)
|
||||
.filter(
|
||||
NotificationAllTimeView.created_at >= start_date,
|
||||
NotificationAllTimeView.created_at < end_date,
|
||||
NotificationAllTimeView.notification_type == notification_type,
|
||||
NotificationAllTimeView.service_id == service_id,
|
||||
NotificationAllTimeView.key_type.in_((KeyType.NORMAL, KeyType.TEAM)),
|
||||
)
|
||||
.group_by(
|
||||
NotificationAllTimeView.template_id,
|
||||
NotificationAllTimeView.template_id,
|
||||
"job_id",
|
||||
NotificationAllTimeView.key_type,
|
||||
NotificationAllTimeView.status,
|
||||
)
|
||||
query = db.session.query(
|
||||
literal(process_day).label("process_day"),
|
||||
NotificationAllTimeView.template_id,
|
||||
literal(service_id).label("service_id"),
|
||||
func.coalesce(NotificationAllTimeView.job_id, '00000000-0000-0000-0000-000000000000').label('job_id'),
|
||||
literal(notification_type).label("notification_type"),
|
||||
NotificationAllTimeView.key_type,
|
||||
NotificationAllTimeView.status,
|
||||
func.count().label('notification_count')
|
||||
).filter(
|
||||
NotificationAllTimeView.created_at >= start_date,
|
||||
NotificationAllTimeView.created_at < end_date,
|
||||
NotificationAllTimeView.notification_type == notification_type,
|
||||
NotificationAllTimeView.service_id == service_id,
|
||||
NotificationAllTimeView.key_type.in_((KEY_TYPE_NORMAL, KEY_TYPE_TEAM)),
|
||||
).group_by(
|
||||
NotificationAllTimeView.template_id,
|
||||
NotificationAllTimeView.template_id,
|
||||
'job_id',
|
||||
NotificationAllTimeView.key_type,
|
||||
NotificationAllTimeView.status
|
||||
)
|
||||
|
||||
db.session.connection().execute(
|
||||
@@ -73,113 +79,89 @@ def update_fact_notification_status(process_day, notification_type, service_id):
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.key_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
FactNotificationStatus.notification_count,
|
||||
FactNotificationStatus.notification_count
|
||||
],
|
||||
query,
|
||||
query
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def fetch_notification_status_for_service_by_month(start_date, end_date, service_id):
|
||||
return (
|
||||
db.session.query(
|
||||
func.date_trunc("month", FactNotificationStatus.local_date).label("month"),
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
func.sum(FactNotificationStatus.notification_count).label("count"),
|
||||
)
|
||||
.filter(
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date < end_date,
|
||||
FactNotificationStatus.key_type != KeyType.TEST,
|
||||
)
|
||||
.group_by(
|
||||
func.date_trunc("month", FactNotificationStatus.local_date).label("month"),
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def fetch_notification_status_for_service_for_day(fetch_day, service_id):
|
||||
return (
|
||||
db.session.query(
|
||||
# return current month as a datetime so the data has the same shape as the ft_notification_status query
|
||||
literal(fetch_day.replace(day=1), type_=DateTime).label("month"),
|
||||
Notification.notification_type,
|
||||
Notification.status.label("notification_status"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.filter(
|
||||
Notification.created_at >= get_midnight_in_utc(fetch_day),
|
||||
Notification.created_at
|
||||
< get_midnight_in_utc(fetch_day + timedelta(days=1)),
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
)
|
||||
.group_by(Notification.notification_type, Notification.status)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def fetch_notification_status_for_service_for_today_and_7_previous_days(
|
||||
service_id, by_template=False, limit_days=7
|
||||
):
|
||||
start_date = midnight_n_days_ago(limit_days)
|
||||
now = datetime.utcnow()
|
||||
stats_for_7_days = db.session.query(
|
||||
FactNotificationStatus.notification_type.cast(db.Text).label(
|
||||
"notification_type"
|
||||
),
|
||||
FactNotificationStatus.notification_status.cast(db.Text).label("status"),
|
||||
*(
|
||||
[FactNotificationStatus.template_id.label("template_id")]
|
||||
if by_template
|
||||
else []
|
||||
),
|
||||
FactNotificationStatus.notification_count.label("count"),
|
||||
return db.session.query(
|
||||
func.date_trunc('month', FactNotificationStatus.local_date).label('month'),
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
func.sum(FactNotificationStatus.notification_count).label('count')
|
||||
).filter(
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.key_type != KeyType.TEST,
|
||||
FactNotificationStatus.local_date < end_date,
|
||||
FactNotificationStatus.key_type != KEY_TYPE_TEST
|
||||
).group_by(
|
||||
func.date_trunc('month', FactNotificationStatus.local_date).label('month'),
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status
|
||||
).all()
|
||||
|
||||
|
||||
def fetch_notification_status_for_service_for_day(bst_day, service_id):
|
||||
return db.session.query(
|
||||
# return current month as a datetime so the data has the same shape as the ft_notification_status query
|
||||
literal(bst_day.replace(day=1), type_=DateTime).label('month'),
|
||||
Notification.notification_type,
|
||||
Notification.status.label('notification_status'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Notification.created_at >= get_local_midnight_in_utc(bst_day),
|
||||
Notification.created_at < get_local_midnight_in_utc(bst_day + timedelta(days=1)),
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KEY_TYPE_TEST
|
||||
).group_by(
|
||||
Notification.notification_type,
|
||||
Notification.status
|
||||
).all()
|
||||
|
||||
|
||||
def fetch_notification_status_for_service_for_today_and_7_previous_days(service_id, by_template=False, limit_days=7):
|
||||
start_date = midnight_n_days_ago(limit_days)
|
||||
now = datetime.utcnow()
|
||||
stats_for_7_days = db.session.query(
|
||||
FactNotificationStatus.notification_type.label('notification_type'),
|
||||
FactNotificationStatus.notification_status.label('status'),
|
||||
*([FactNotificationStatus.template_id.label('template_id')] if by_template else []),
|
||||
FactNotificationStatus.notification_count.label('count')
|
||||
).filter(
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.key_type != KEY_TYPE_TEST
|
||||
)
|
||||
|
||||
stats_for_today = (
|
||||
db.session.query(
|
||||
Notification.notification_type.cast(db.Text),
|
||||
Notification.status.cast(db.Text),
|
||||
*([Notification.template_id] if by_template else []),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.filter(
|
||||
Notification.created_at >= get_midnight_in_utc(now),
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
)
|
||||
.group_by(
|
||||
Notification.notification_type,
|
||||
*([Notification.template_id] if by_template else []),
|
||||
Notification.status,
|
||||
)
|
||||
stats_for_today = db.session.query(
|
||||
Notification.notification_type.cast(db.Text),
|
||||
Notification.status,
|
||||
*([Notification.template_id] if by_template else []),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Notification.created_at >= get_local_midnight_in_utc(now),
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KEY_TYPE_TEST
|
||||
).group_by(
|
||||
Notification.notification_type,
|
||||
*([Notification.template_id] if by_template else []),
|
||||
Notification.status
|
||||
)
|
||||
|
||||
all_stats_table = stats_for_7_days.union_all(stats_for_today).subquery()
|
||||
|
||||
query = db.session.query(
|
||||
*(
|
||||
[
|
||||
Template.name.label("template_name"),
|
||||
False, # TODO: this is related to is_precompiled_letter
|
||||
all_stats_table.c.template_id,
|
||||
]
|
||||
if by_template
|
||||
else []
|
||||
),
|
||||
*([
|
||||
Template.name.label("template_name"),
|
||||
False, # TODO: this is related to is_precompiled_letter
|
||||
all_stats_table.c.template_id
|
||||
] if by_template else []),
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label("count"),
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label('count'),
|
||||
)
|
||||
|
||||
if by_template:
|
||||
@@ -193,169 +175,152 @@ def fetch_notification_status_for_service_for_today_and_7_previous_days(
|
||||
|
||||
|
||||
def fetch_notification_status_totals_for_all_services(start_date, end_date):
|
||||
stats = (
|
||||
db.session.query(
|
||||
FactNotificationStatus.notification_type.cast(db.Text).label(
|
||||
"notification_type"
|
||||
),
|
||||
FactNotificationStatus.notification_status.cast(db.Text).label("status"),
|
||||
FactNotificationStatus.key_type.cast(db.Text).label("key_type"),
|
||||
func.sum(FactNotificationStatus.notification_count).label("count"),
|
||||
)
|
||||
.filter(
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
)
|
||||
.group_by(
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
FactNotificationStatus.key_type,
|
||||
)
|
||||
stats = db.session.query(
|
||||
FactNotificationStatus.notification_type.label('notification_type'),
|
||||
FactNotificationStatus.notification_status.label('status'),
|
||||
FactNotificationStatus.key_type.label('key_type'),
|
||||
func.sum(FactNotificationStatus.notification_count).label('count')
|
||||
).filter(
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date
|
||||
).group_by(
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
FactNotificationStatus.key_type,
|
||||
)
|
||||
today = get_midnight_in_utc(datetime.utcnow())
|
||||
today = get_local_midnight_in_utc(datetime.utcnow())
|
||||
if start_date <= datetime.utcnow().date() <= end_date:
|
||||
stats_for_today = (
|
||||
db.session.query(
|
||||
Notification.notification_type.cast(db.Text).label("notification_type"),
|
||||
Notification.status.cast(db.Text),
|
||||
Notification.key_type.cast(db.Text),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.filter(Notification.created_at >= today)
|
||||
.group_by(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
Notification.key_type,
|
||||
)
|
||||
stats_for_today = db.session.query(
|
||||
Notification.notification_type.cast(db.Text).label('notification_type'),
|
||||
Notification.status,
|
||||
Notification.key_type,
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Notification.created_at >= today
|
||||
).group_by(
|
||||
Notification.notification_type.cast(db.Text),
|
||||
Notification.status,
|
||||
Notification.key_type,
|
||||
)
|
||||
all_stats_table = stats.union_all(stats_for_today).subquery()
|
||||
query = (
|
||||
db.session.query(
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
all_stats_table.c.key_type,
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label("count"),
|
||||
)
|
||||
.group_by(
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
all_stats_table.c.key_type,
|
||||
)
|
||||
.order_by(all_stats_table.c.notification_type)
|
||||
query = db.session.query(
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
all_stats_table.c.key_type,
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label('count'),
|
||||
).group_by(
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
all_stats_table.c.key_type,
|
||||
).order_by(
|
||||
all_stats_table.c.notification_type
|
||||
)
|
||||
else:
|
||||
query = stats.order_by(FactNotificationStatus.notification_type)
|
||||
query = stats.order_by(
|
||||
FactNotificationStatus.notification_type
|
||||
)
|
||||
return query.all()
|
||||
|
||||
|
||||
def fetch_notification_statuses_for_job(job_id):
|
||||
return (
|
||||
db.session.query(
|
||||
FactNotificationStatus.notification_status.label("status"),
|
||||
func.sum(FactNotificationStatus.notification_count).label("count"),
|
||||
)
|
||||
.filter(
|
||||
FactNotificationStatus.job_id == job_id,
|
||||
)
|
||||
.group_by(FactNotificationStatus.notification_status)
|
||||
.all()
|
||||
)
|
||||
return db.session.query(
|
||||
FactNotificationStatus.notification_status.label('status'),
|
||||
func.sum(FactNotificationStatus.notification_count).label('count'),
|
||||
).filter(
|
||||
FactNotificationStatus.job_id == job_id,
|
||||
).group_by(
|
||||
FactNotificationStatus.notification_status
|
||||
).all()
|
||||
|
||||
|
||||
def fetch_stats_for_all_services_by_date_range(
|
||||
start_date, end_date, include_from_test_key=True
|
||||
):
|
||||
stats = (
|
||||
db.session.query(
|
||||
FactNotificationStatus.service_id.label("service_id"),
|
||||
Service.name.label("name"),
|
||||
Service.restricted.label("restricted"),
|
||||
Service.active.label("active"),
|
||||
Service.created_at.label("created_at"),
|
||||
FactNotificationStatus.notification_type.cast(db.Text).label(
|
||||
"notification_type"
|
||||
),
|
||||
FactNotificationStatus.notification_status.cast(db.Text).label("status"),
|
||||
func.sum(FactNotificationStatus.notification_count).label("count"),
|
||||
)
|
||||
.filter(
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
FactNotificationStatus.service_id == Service.id,
|
||||
)
|
||||
.group_by(
|
||||
FactNotificationStatus.service_id.label("service_id"),
|
||||
Service.name,
|
||||
Service.restricted,
|
||||
Service.active,
|
||||
Service.created_at,
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
)
|
||||
.order_by(
|
||||
FactNotificationStatus.service_id, FactNotificationStatus.notification_type
|
||||
)
|
||||
def fetch_stats_for_all_services_by_date_range(start_date, end_date, include_from_test_key=True):
|
||||
stats = db.session.query(
|
||||
FactNotificationStatus.service_id.label('service_id'),
|
||||
Service.name.label('name'),
|
||||
Service.restricted.label('restricted'),
|
||||
Service.research_mode.label('research_mode'),
|
||||
Service.active.label('active'),
|
||||
Service.created_at.label('created_at'),
|
||||
FactNotificationStatus.notification_type.label('notification_type'),
|
||||
FactNotificationStatus.notification_status.label('status'),
|
||||
func.sum(FactNotificationStatus.notification_count).label('count')
|
||||
).filter(
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
FactNotificationStatus.service_id == Service.id,
|
||||
).group_by(
|
||||
FactNotificationStatus.service_id.label('service_id'),
|
||||
Service.name,
|
||||
Service.restricted,
|
||||
Service.research_mode,
|
||||
Service.active,
|
||||
Service.created_at,
|
||||
FactNotificationStatus.notification_type,
|
||||
FactNotificationStatus.notification_status,
|
||||
).order_by(
|
||||
FactNotificationStatus.service_id,
|
||||
FactNotificationStatus.notification_type
|
||||
)
|
||||
if not include_from_test_key:
|
||||
stats = stats.filter(FactNotificationStatus.key_type != KeyType.TEST)
|
||||
stats = stats.filter(FactNotificationStatus.key_type != KEY_TYPE_TEST)
|
||||
|
||||
if start_date <= datetime.utcnow().date() <= end_date:
|
||||
today = get_midnight_in_utc(datetime.utcnow())
|
||||
subquery = (
|
||||
db.session.query(
|
||||
Notification.notification_type.label("notification_type"),
|
||||
Notification.status.label("status"),
|
||||
Notification.service_id.label("service_id"),
|
||||
func.count(Notification.id).label("count"),
|
||||
)
|
||||
.filter(Notification.created_at >= today)
|
||||
.group_by(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
Notification.service_id,
|
||||
)
|
||||
today = get_local_midnight_in_utc(datetime.utcnow())
|
||||
subquery = db.session.query(
|
||||
Notification.notification_type.cast(db.Text).label('notification_type'),
|
||||
Notification.status.label('status'),
|
||||
Notification.service_id.label('service_id'),
|
||||
func.count(Notification.id).label('count')
|
||||
).filter(
|
||||
Notification.created_at >= today
|
||||
).group_by(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
Notification.service_id
|
||||
)
|
||||
if not include_from_test_key:
|
||||
subquery = subquery.filter(Notification.key_type != KeyType.TEST)
|
||||
subquery = subquery.filter(Notification.key_type != KEY_TYPE_TEST)
|
||||
subquery = subquery.subquery()
|
||||
|
||||
stats_for_today = db.session.query(
|
||||
Service.id.label("service_id"),
|
||||
Service.name.label("name"),
|
||||
Service.restricted.label("restricted"),
|
||||
Service.active.label("active"),
|
||||
Service.created_at.label("created_at"),
|
||||
subquery.c.notification_type.cast(db.Text).label("notification_type"),
|
||||
subquery.c.status.cast(db.Text).label("status"),
|
||||
subquery.c.count.label("count"),
|
||||
).outerjoin(subquery, subquery.c.service_id == Service.id)
|
||||
Service.id.label('service_id'),
|
||||
Service.name.label('name'),
|
||||
Service.restricted.label('restricted'),
|
||||
Service.research_mode.label('research_mode'),
|
||||
Service.active.label('active'),
|
||||
Service.created_at.label('created_at'),
|
||||
subquery.c.notification_type.label('notification_type'),
|
||||
subquery.c.status.label('status'),
|
||||
subquery.c.count.label('count')
|
||||
).outerjoin(
|
||||
subquery,
|
||||
subquery.c.service_id == Service.id
|
||||
)
|
||||
|
||||
all_stats_table = stats.union_all(stats_for_today).subquery()
|
||||
query = (
|
||||
db.session.query(
|
||||
all_stats_table.c.service_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.restricted,
|
||||
all_stats_table.c.active,
|
||||
all_stats_table.c.created_at,
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label("count"),
|
||||
)
|
||||
.group_by(
|
||||
all_stats_table.c.service_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.restricted,
|
||||
all_stats_table.c.active,
|
||||
all_stats_table.c.created_at,
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
)
|
||||
.order_by(
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
)
|
||||
query = db.session.query(
|
||||
all_stats_table.c.service_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.restricted,
|
||||
all_stats_table.c.research_mode,
|
||||
all_stats_table.c.active,
|
||||
all_stats_table.c.created_at,
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label('count'),
|
||||
).group_by(
|
||||
all_stats_table.c.service_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.restricted,
|
||||
all_stats_table.c.research_mode,
|
||||
all_stats_table.c.active,
|
||||
all_stats_table.c.created_at,
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status,
|
||||
).order_by(
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.notification_type,
|
||||
all_stats_table.c.status
|
||||
)
|
||||
else:
|
||||
query = stats
|
||||
@@ -364,89 +329,77 @@ def fetch_stats_for_all_services_by_date_range(
|
||||
|
||||
def fetch_monthly_template_usage_for_service(start_date, end_date, service_id):
|
||||
# services_dao.replaces dao_fetch_monthly_historical_usage_by_template_for_service
|
||||
stats = (
|
||||
db.session.query(
|
||||
FactNotificationStatus.template_id.label("template_id"),
|
||||
Template.name.label("name"),
|
||||
Template.template_type.label("template_type"),
|
||||
extract("month", FactNotificationStatus.local_date).label("month"),
|
||||
extract("year", FactNotificationStatus.local_date).label("year"),
|
||||
func.sum(FactNotificationStatus.notification_count).label("count"),
|
||||
)
|
||||
.join(Template, FactNotificationStatus.template_id == Template.id)
|
||||
.filter(
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
FactNotificationStatus.key_type != KeyType.TEST,
|
||||
FactNotificationStatus.notification_status != NotificationStatus.CANCELLED,
|
||||
)
|
||||
.group_by(
|
||||
FactNotificationStatus.template_id,
|
||||
Template.name,
|
||||
Template.template_type,
|
||||
extract("month", FactNotificationStatus.local_date).label("month"),
|
||||
extract("year", FactNotificationStatus.local_date).label("year"),
|
||||
)
|
||||
.order_by(
|
||||
extract("year", FactNotificationStatus.local_date),
|
||||
extract("month", FactNotificationStatus.local_date),
|
||||
Template.name,
|
||||
)
|
||||
stats = db.session.query(
|
||||
FactNotificationStatus.template_id.label('template_id'),
|
||||
Template.name.label('name'),
|
||||
Template.template_type.label('template_type'),
|
||||
extract('month', FactNotificationStatus.local_date).label('month'),
|
||||
extract('year', FactNotificationStatus.local_date).label('year'),
|
||||
func.sum(FactNotificationStatus.notification_count).label('count')
|
||||
).join(
|
||||
Template, FactNotificationStatus.template_id == Template.id
|
||||
).filter(
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
FactNotificationStatus.key_type != KEY_TYPE_TEST,
|
||||
FactNotificationStatus.notification_status != NOTIFICATION_CANCELLED,
|
||||
).group_by(
|
||||
FactNotificationStatus.template_id,
|
||||
Template.name,
|
||||
Template.template_type,
|
||||
extract('month', FactNotificationStatus.local_date).label('month'),
|
||||
extract('year', FactNotificationStatus.local_date).label('year'),
|
||||
).order_by(
|
||||
extract('year', FactNotificationStatus.local_date),
|
||||
extract('month', FactNotificationStatus.local_date),
|
||||
Template.name
|
||||
)
|
||||
|
||||
if start_date <= datetime.utcnow() <= end_date:
|
||||
today = get_midnight_in_utc(datetime.utcnow())
|
||||
month = get_month_from_utc_column(Notification.created_at)
|
||||
today = get_local_midnight_in_utc(datetime.utcnow())
|
||||
month = get_local_month_from_utc_column(Notification.created_at)
|
||||
|
||||
stats_for_today = (
|
||||
db.session.query(
|
||||
Notification.template_id.label("template_id"),
|
||||
Template.name.label("name"),
|
||||
Template.template_type.label("template_type"),
|
||||
extract("month", month).label("month"),
|
||||
extract("year", month).label("year"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.join(
|
||||
Template,
|
||||
Notification.template_id == Template.id,
|
||||
)
|
||||
.filter(
|
||||
Notification.created_at >= today,
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
Notification.status != NotificationStatus.CANCELLED,
|
||||
)
|
||||
.group_by(
|
||||
Notification.template_id,
|
||||
Template.hidden,
|
||||
Template.name,
|
||||
Template.template_type,
|
||||
month,
|
||||
)
|
||||
stats_for_today = db.session.query(
|
||||
Notification.template_id.label('template_id'),
|
||||
Template.name.label('name'),
|
||||
Template.template_type.label('template_type'),
|
||||
extract('month', month).label('month'),
|
||||
extract('year', month).label('year'),
|
||||
func.count().label('count')
|
||||
).join(
|
||||
Template, Notification.template_id == Template.id,
|
||||
).filter(
|
||||
Notification.created_at >= today,
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KEY_TYPE_TEST,
|
||||
Notification.status != NOTIFICATION_CANCELLED
|
||||
).group_by(
|
||||
Notification.template_id,
|
||||
Template.hidden,
|
||||
Template.name,
|
||||
Template.template_type,
|
||||
month
|
||||
)
|
||||
|
||||
all_stats_table = stats.union_all(stats_for_today).subquery()
|
||||
query = (
|
||||
db.session.query(
|
||||
all_stats_table.c.template_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.template_type,
|
||||
func.cast(all_stats_table.c.month, Integer).label("month"),
|
||||
func.cast(all_stats_table.c.year, Integer).label("year"),
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label("count"),
|
||||
)
|
||||
.group_by(
|
||||
all_stats_table.c.template_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.template_type,
|
||||
all_stats_table.c.month,
|
||||
all_stats_table.c.year,
|
||||
)
|
||||
.order_by(
|
||||
all_stats_table.c.year, all_stats_table.c.month, all_stats_table.c.name
|
||||
)
|
||||
query = db.session.query(
|
||||
all_stats_table.c.template_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.template_type,
|
||||
func.cast(all_stats_table.c.month, Integer).label('month'),
|
||||
func.cast(all_stats_table.c.year, Integer).label('year'),
|
||||
func.cast(func.sum(all_stats_table.c.count), Integer).label('count'),
|
||||
).group_by(
|
||||
all_stats_table.c.template_id,
|
||||
all_stats_table.c.name,
|
||||
all_stats_table.c.template_type,
|
||||
all_stats_table.c.month,
|
||||
all_stats_table.c.year,
|
||||
).order_by(
|
||||
all_stats_table.c.year,
|
||||
all_stats_table.c.month,
|
||||
all_stats_table.c.name
|
||||
)
|
||||
else:
|
||||
query = stats
|
||||
@@ -454,154 +407,92 @@ def fetch_monthly_template_usage_for_service(start_date, end_date, service_id):
|
||||
|
||||
|
||||
def get_total_notifications_for_date_range(start_date, end_date):
|
||||
query = (
|
||||
db.session.query(
|
||||
FactNotificationStatus.local_date.label("local_date"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_type
|
||||
== NotificationType.EMAIL,
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("emails"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_type
|
||||
== NotificationType.SMS,
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("sms"),
|
||||
)
|
||||
.filter(
|
||||
FactNotificationStatus.key_type != KeyType.TEST,
|
||||
)
|
||||
.group_by(FactNotificationStatus.local_date)
|
||||
.order_by(FactNotificationStatus.local_date)
|
||||
query = db.session.query(
|
||||
FactNotificationStatus.local_date.cast(db.Text).label("local_date"),
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_type == 'email', FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('emails'),
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_type == 'sms', FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('sms'),
|
||||
).filter(
|
||||
FactNotificationStatus.key_type != KEY_TYPE_TEST,
|
||||
).group_by(
|
||||
FactNotificationStatus.local_date
|
||||
).order_by(
|
||||
FactNotificationStatus.local_date
|
||||
)
|
||||
if start_date and end_date:
|
||||
query = query.filter(
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
FactNotificationStatus.local_date <= end_date
|
||||
)
|
||||
return query.all()
|
||||
|
||||
|
||||
def fetch_monthly_notification_statuses_per_service(start_date, end_date):
|
||||
return (
|
||||
db.session.query(
|
||||
func.date_trunc("month", FactNotificationStatus.local_date)
|
||||
.cast(Date)
|
||||
.label("date_created"),
|
||||
Service.id.label("service_id"),
|
||||
Service.name.label("service_name"),
|
||||
FactNotificationStatus.notification_type,
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_status.in_(
|
||||
[NotificationStatus.SENDING, NotificationStatus.PENDING]
|
||||
),
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("count_sending"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_status
|
||||
== NotificationStatus.DELIVERED,
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("count_delivered"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_status.in_(
|
||||
[
|
||||
NotificationStatus.TECHNICAL_FAILURE,
|
||||
NotificationStatus.FAILED,
|
||||
]
|
||||
),
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("count_technical_failure"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_status
|
||||
== NotificationStatus.TEMPORARY_FAILURE,
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("count_temporary_failure"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_status
|
||||
== NotificationStatus.PERMANENT_FAILURE,
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("count_permanent_failure"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactNotificationStatus.notification_status
|
||||
== NotificationStatus.SENT,
|
||||
FactNotificationStatus.notification_count,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("count_sent"),
|
||||
)
|
||||
.join(Service, FactNotificationStatus.service_id == Service.id)
|
||||
.filter(
|
||||
FactNotificationStatus.notification_status != NotificationStatus.CREATED,
|
||||
Service.active.is_(True),
|
||||
FactNotificationStatus.key_type != KeyType.TEST,
|
||||
Service.restricted.is_(False),
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
)
|
||||
.group_by(
|
||||
Service.id,
|
||||
Service.name,
|
||||
func.date_trunc("month", FactNotificationStatus.local_date).cast(Date),
|
||||
FactNotificationStatus.notification_type,
|
||||
)
|
||||
.order_by(
|
||||
func.date_trunc("month", FactNotificationStatus.local_date).cast(Date),
|
||||
Service.id,
|
||||
FactNotificationStatus.notification_type,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return db.session.query(
|
||||
func.date_trunc('month', FactNotificationStatus.local_date).cast(Date).label('date_created'),
|
||||
Service.id.label('service_id'),
|
||||
Service.name.label('service_name'),
|
||||
FactNotificationStatus.notification_type,
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_status.in_([NOTIFICATION_SENDING, NOTIFICATION_PENDING]),
|
||||
FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('count_sending'),
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_status == NOTIFICATION_DELIVERED,
|
||||
FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('count_delivered'),
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_status.in_([NOTIFICATION_TECHNICAL_FAILURE, NOTIFICATION_FAILED]),
|
||||
FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('count_technical_failure'),
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_status == NOTIFICATION_TEMPORARY_FAILURE,
|
||||
FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('count_temporary_failure'),
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_status == NOTIFICATION_PERMANENT_FAILURE,
|
||||
FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('count_permanent_failure'),
|
||||
func.sum(case(
|
||||
[
|
||||
(FactNotificationStatus.notification_status == NOTIFICATION_SENT,
|
||||
FactNotificationStatus.notification_count)
|
||||
],
|
||||
else_=0)).label('count_sent'),
|
||||
).join(
|
||||
Service, FactNotificationStatus.service_id == Service.id
|
||||
).filter(
|
||||
FactNotificationStatus.notification_status != NOTIFICATION_CREATED,
|
||||
Service.active.is_(True),
|
||||
FactNotificationStatus.key_type != KEY_TYPE_TEST,
|
||||
Service.research_mode.is_(False),
|
||||
Service.restricted.is_(False),
|
||||
FactNotificationStatus.local_date >= start_date,
|
||||
FactNotificationStatus.local_date <= end_date,
|
||||
).group_by(
|
||||
Service.id,
|
||||
Service.name,
|
||||
func.date_trunc('month', FactNotificationStatus.local_date).cast(Date),
|
||||
FactNotificationStatus.notification_type,
|
||||
).order_by(
|
||||
func.date_trunc('month', FactNotificationStatus.local_date).cast(Date),
|
||||
Service.id,
|
||||
FactNotificationStatus.notification_type,
|
||||
).all()
|
||||
|
||||
@@ -10,56 +10,44 @@ from app.models import FactProcessingTime
|
||||
|
||||
@autocommit
|
||||
def insert_update_processing_time(processing_time):
|
||||
"""
|
||||
'''
|
||||
This uses the Postgres upsert to avoid race conditions when two threads try and insert
|
||||
at the same row. The excluded object refers to values that we tried to insert but were
|
||||
rejected.
|
||||
http://docs.sqlalchemy.org/en/latest/dialects/postgresql.html#insert-on-conflict-upsert
|
||||
"""
|
||||
'''
|
||||
table = FactProcessingTime.__table__
|
||||
stmt = insert(table).values(
|
||||
local_date=processing_time.local_date,
|
||||
messages_total=processing_time.messages_total,
|
||||
messages_within_10_secs=processing_time.messages_within_10_secs,
|
||||
messages_within_10_secs=processing_time.messages_within_10_secs
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[table.c.local_date],
|
||||
set_={
|
||||
"messages_total": stmt.excluded.messages_total,
|
||||
"messages_within_10_secs": stmt.excluded.messages_within_10_secs,
|
||||
"updated_at": datetime.utcnow(),
|
||||
},
|
||||
'messages_total': stmt.excluded.messages_total,
|
||||
'messages_within_10_secs': stmt.excluded.messages_within_10_secs,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
db.session.connection().execute(stmt)
|
||||
|
||||
|
||||
def get_processing_time_percentage_for_date_range(start_date, end_date):
|
||||
query = (
|
||||
db.session.query(
|
||||
FactProcessingTime.local_date.cast(db.Text).label("date"),
|
||||
FactProcessingTime.messages_total,
|
||||
FactProcessingTime.messages_within_10_secs,
|
||||
case(
|
||||
[
|
||||
(
|
||||
FactProcessingTime.messages_total > 0,
|
||||
(
|
||||
(
|
||||
FactProcessingTime.messages_within_10_secs
|
||||
/ FactProcessingTime.messages_total.cast(db.Float)
|
||||
)
|
||||
* 100
|
||||
),
|
||||
),
|
||||
(FactProcessingTime.messages_total == 0, 100.0),
|
||||
]
|
||||
).label("percentage"),
|
||||
)
|
||||
.filter(
|
||||
FactProcessingTime.local_date >= start_date,
|
||||
FactProcessingTime.local_date <= end_date,
|
||||
)
|
||||
.order_by(FactProcessingTime.local_date)
|
||||
)
|
||||
query = db.session.query(
|
||||
FactProcessingTime.local_date.cast(db.Text).label("date"),
|
||||
FactProcessingTime.messages_total,
|
||||
FactProcessingTime.messages_within_10_secs,
|
||||
case([
|
||||
(
|
||||
FactProcessingTime.messages_total > 0,
|
||||
((FactProcessingTime.messages_within_10_secs / FactProcessingTime.messages_total.cast(db.Float)) * 100)
|
||||
),
|
||||
(FactProcessingTime.messages_total == 0, 100.0)
|
||||
]).label("percentage")
|
||||
).filter(
|
||||
FactProcessingTime.local_date >= start_date,
|
||||
FactProcessingTime.local_date <= end_date
|
||||
).order_by(FactProcessingTime.local_date)
|
||||
|
||||
return query.all()
|
||||
|
||||
@@ -8,9 +8,7 @@ def dao_get_inbound_numbers():
|
||||
|
||||
|
||||
def dao_get_available_inbound_numbers():
|
||||
return InboundNumber.query.filter(
|
||||
InboundNumber.active, InboundNumber.service_id.is_(None)
|
||||
).all()
|
||||
return InboundNumber.query.filter(InboundNumber.active, InboundNumber.service_id.is_(None)).all()
|
||||
|
||||
|
||||
def dao_get_inbound_number_for_service(service_id):
|
||||
@@ -29,9 +27,7 @@ def dao_set_inbound_number_to_service(service_id, inbound_number):
|
||||
|
||||
@autocommit
|
||||
def dao_set_inbound_number_active_flag(service_id, active):
|
||||
inbound_number = InboundNumber.query.filter(
|
||||
InboundNumber.service_id == service_id
|
||||
).first()
|
||||
inbound_number = InboundNumber.query.filter(InboundNumber.service_id == service_id).first()
|
||||
inbound_number.active = active
|
||||
|
||||
db.session.add(inbound_number)
|
||||
@@ -40,8 +36,12 @@ def dao_set_inbound_number_active_flag(service_id, active):
|
||||
@autocommit
|
||||
def dao_allocate_number_for_service(service_id, inbound_number_id):
|
||||
updated = InboundNumber.query.filter_by(
|
||||
id=inbound_number_id, active=True, service_id=None
|
||||
).update({"service_id": service_id})
|
||||
id=inbound_number_id,
|
||||
active=True,
|
||||
service_id=None
|
||||
).update(
|
||||
{"service_id": service_id}
|
||||
)
|
||||
if not updated:
|
||||
raise Exception("Inbound number: {} is not available".format(inbound_number_id))
|
||||
return InboundNumber.query.get(inbound_number_id)
|
||||
|
||||
@@ -5,8 +5,13 @@ from sqlalchemy.orm import aliased
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.enums import NotificationType
|
||||
from app.models import InboundSms, InboundSmsHistory, Service, ServiceDataRetention
|
||||
from app.models import (
|
||||
SMS_TYPE,
|
||||
InboundSms,
|
||||
InboundSmsHistory,
|
||||
Service,
|
||||
ServiceDataRetention,
|
||||
)
|
||||
from app.utils import midnight_n_days_ago
|
||||
|
||||
|
||||
@@ -15,10 +20,10 @@ def dao_create_inbound_sms(inbound_sms):
|
||||
db.session.add(inbound_sms)
|
||||
|
||||
|
||||
def dao_get_inbound_sms_for_service(
|
||||
service_id, user_number=None, *, limit_days=None, limit=None
|
||||
):
|
||||
q = InboundSms.query.filter(InboundSms.service_id == service_id).order_by(
|
||||
def dao_get_inbound_sms_for_service(service_id, user_number=None, *, limit_days=None, limit=None):
|
||||
q = InboundSms.query.filter(
|
||||
InboundSms.service_id == service_id
|
||||
).order_by(
|
||||
InboundSms.created_at.desc()
|
||||
)
|
||||
if limit_days is not None:
|
||||
@@ -35,32 +40,31 @@ def dao_get_inbound_sms_for_service(
|
||||
|
||||
|
||||
def dao_get_paginated_inbound_sms_for_service_for_public_api(
|
||||
service_id, older_than=None, page_size=None
|
||||
service_id,
|
||||
older_than=None,
|
||||
page_size=None
|
||||
):
|
||||
if page_size is None:
|
||||
page_size = current_app.config["PAGE_SIZE"]
|
||||
page_size = current_app.config['PAGE_SIZE']
|
||||
|
||||
filters = [InboundSms.service_id == service_id]
|
||||
|
||||
if older_than:
|
||||
older_than_created_at = (
|
||||
db.session.query(InboundSms.created_at)
|
||||
.filter(InboundSms.id == older_than)
|
||||
.as_scalar()
|
||||
)
|
||||
older_than_created_at = db.session.query(
|
||||
InboundSms.created_at).filter(InboundSms.id == older_than).as_scalar()
|
||||
filters.append(InboundSms.created_at < older_than_created_at)
|
||||
|
||||
query = InboundSms.query.filter(*filters)
|
||||
|
||||
return (
|
||||
query.order_by(desc(InboundSms.created_at)).paginate(per_page=page_size).items
|
||||
)
|
||||
return query.order_by(desc(InboundSms.created_at)).paginate(
|
||||
per_page=page_size
|
||||
).items
|
||||
|
||||
|
||||
def dao_count_inbound_sms_for_service(service_id, limit_days):
|
||||
return InboundSms.query.filter(
|
||||
InboundSms.service_id == service_id,
|
||||
InboundSms.created_at >= midnight_n_days_ago(limit_days),
|
||||
InboundSms.created_at >= midnight_n_days_ago(limit_days)
|
||||
).count()
|
||||
|
||||
|
||||
@@ -73,14 +77,14 @@ def _insert_inbound_sms_history(subquery, query_limit=10000):
|
||||
InboundSms.notify_number,
|
||||
InboundSms.provider_date,
|
||||
InboundSms.provider_reference,
|
||||
InboundSms.provider,
|
||||
InboundSms.provider
|
||||
).filter(InboundSms.id.in_(subquery))
|
||||
inbound_sms_count = inbound_sms_query.count()
|
||||
|
||||
while offset < inbound_sms_count:
|
||||
statement = insert(InboundSmsHistory).from_select(
|
||||
InboundSmsHistory.__table__.c,
|
||||
inbound_sms_query.limit(query_limit).offset(offset),
|
||||
inbound_sms_query.limit(query_limit).offset(offset)
|
||||
)
|
||||
|
||||
statement = statement.on_conflict_do_nothing(
|
||||
@@ -94,12 +98,14 @@ def _insert_inbound_sms_history(subquery, query_limit=10000):
|
||||
def _delete_inbound_sms(datetime_to_delete_from, query_filter):
|
||||
query_limit = 10000
|
||||
|
||||
subquery = (
|
||||
db.session.query(InboundSms.id)
|
||||
.filter(InboundSms.created_at < datetime_to_delete_from, *query_filter)
|
||||
.limit(query_limit)
|
||||
.subquery()
|
||||
)
|
||||
subquery = db.session.query(
|
||||
InboundSms.id
|
||||
).filter(
|
||||
InboundSms.created_at < datetime_to_delete_from,
|
||||
*query_filter
|
||||
).limit(
|
||||
query_limit
|
||||
).subquery()
|
||||
|
||||
deleted = 0
|
||||
# set to nonzero just to enter the loop
|
||||
@@ -107,9 +113,7 @@ def _delete_inbound_sms(datetime_to_delete_from, query_filter):
|
||||
while number_deleted > 0:
|
||||
_insert_inbound_sms_history(subquery, query_limit=query_limit)
|
||||
|
||||
number_deleted = InboundSms.query.filter(InboundSms.id.in_(subquery)).delete(
|
||||
synchronize_session="fetch"
|
||||
)
|
||||
number_deleted = InboundSms.query.filter(InboundSms.id.in_(subquery)).delete(synchronize_session='fetch')
|
||||
deleted += number_deleted
|
||||
|
||||
return deleted
|
||||
@@ -117,54 +121,47 @@ def _delete_inbound_sms(datetime_to_delete_from, query_filter):
|
||||
|
||||
@autocommit
|
||||
def delete_inbound_sms_older_than_retention():
|
||||
current_app.logger.info(
|
||||
"Deleting inbound sms for services with flexible data retention"
|
||||
)
|
||||
current_app.logger.info('Deleting inbound sms for services with flexible data retention')
|
||||
|
||||
flexible_data_retention = (
|
||||
ServiceDataRetention.query.join(
|
||||
ServiceDataRetention.service, Service.inbound_number
|
||||
)
|
||||
.filter(ServiceDataRetention.notification_type == NotificationType.SMS)
|
||||
.all()
|
||||
)
|
||||
flexible_data_retention = ServiceDataRetention.query.join(
|
||||
ServiceDataRetention.service,
|
||||
Service.inbound_number
|
||||
).filter(
|
||||
ServiceDataRetention.notification_type == SMS_TYPE
|
||||
).all()
|
||||
|
||||
deleted = 0
|
||||
|
||||
for f in flexible_data_retention:
|
||||
n_days_ago = midnight_n_days_ago(f.days_of_retention)
|
||||
|
||||
current_app.logger.info(
|
||||
"Deleting inbound sms for service id: {}".format(f.service_id)
|
||||
)
|
||||
deleted += _delete_inbound_sms(
|
||||
n_days_ago, query_filter=[InboundSms.service_id == f.service_id]
|
||||
)
|
||||
current_app.logger.info("Deleting inbound sms for service id: {}".format(f.service_id))
|
||||
deleted += _delete_inbound_sms(n_days_ago, query_filter=[InboundSms.service_id == f.service_id])
|
||||
|
||||
current_app.logger.info(
|
||||
"Deleting inbound sms for services without flexible data retention"
|
||||
)
|
||||
current_app.logger.info('Deleting inbound sms for services without flexible data retention')
|
||||
|
||||
seven_days_ago = midnight_n_days_ago(7)
|
||||
|
||||
deleted += _delete_inbound_sms(
|
||||
seven_days_ago,
|
||||
query_filter=[
|
||||
InboundSms.service_id.notin_(x.service_id for x in flexible_data_retention),
|
||||
],
|
||||
)
|
||||
deleted += _delete_inbound_sms(seven_days_ago, query_filter=[
|
||||
InboundSms.service_id.notin_(x.service_id for x in flexible_data_retention),
|
||||
])
|
||||
|
||||
current_app.logger.info("Deleted {} inbound sms".format(deleted))
|
||||
current_app.logger.info('Deleted {} inbound sms'.format(deleted))
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def dao_get_inbound_sms_by_id(service_id, inbound_id):
|
||||
return InboundSms.query.filter_by(id=inbound_id, service_id=service_id).one()
|
||||
return InboundSms.query.filter_by(
|
||||
id=inbound_id,
|
||||
service_id=service_id
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_paginated_most_recent_inbound_sms_by_user_number_for_service(
|
||||
service_id, page, limit_days
|
||||
service_id,
|
||||
page,
|
||||
limit_days
|
||||
):
|
||||
"""
|
||||
This query starts from inbound_sms and joins on to itself to find the most recent row for each user_number.
|
||||
@@ -185,22 +182,24 @@ def dao_get_paginated_most_recent_inbound_sms_by_user_number_for_service(
|
||||
LIMIT 50 OFFSET :page
|
||||
"""
|
||||
t2 = aliased(InboundSms)
|
||||
q = (
|
||||
db.session.query(InboundSms)
|
||||
.outerjoin(
|
||||
t2,
|
||||
and_(
|
||||
InboundSms.user_number == t2.user_number,
|
||||
InboundSms.service_id == t2.service_id,
|
||||
InboundSms.created_at < t2.created_at,
|
||||
),
|
||||
q = db.session.query(
|
||||
InboundSms
|
||||
).outerjoin(
|
||||
t2,
|
||||
and_(
|
||||
InboundSms.user_number == t2.user_number,
|
||||
InboundSms.service_id == t2.service_id,
|
||||
InboundSms.created_at < t2.created_at,
|
||||
)
|
||||
.filter(
|
||||
t2.id == None, # noqa
|
||||
InboundSms.service_id == service_id,
|
||||
InboundSms.created_at >= midnight_n_days_ago(limit_days),
|
||||
)
|
||||
.order_by(InboundSms.created_at.desc())
|
||||
).filter(
|
||||
t2.id == None, # noqa
|
||||
InboundSms.service_id == service_id,
|
||||
InboundSms.created_at >= midnight_n_days_ago(limit_days)
|
||||
).order_by(
|
||||
InboundSms.created_at.desc()
|
||||
)
|
||||
|
||||
return q.paginate(page=page, per_page=current_app.config["PAGE_SIZE"])
|
||||
return q.paginate(
|
||||
page=page,
|
||||
per_page=current_app.config['PAGE_SIZE']
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import db
|
||||
from app.models import InvitedOrganizationUser
|
||||
from app.models import InvitedOrganisationUser
|
||||
|
||||
|
||||
def save_invited_org_user(invited_org_user):
|
||||
@@ -9,29 +9,21 @@ def save_invited_org_user(invited_org_user):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def get_invited_org_user(organization_id, invited_org_user_id):
|
||||
return InvitedOrganizationUser.query.filter_by(
|
||||
organization_id=organization_id, id=invited_org_user_id
|
||||
).one()
|
||||
def get_invited_org_user(organisation_id, invited_org_user_id):
|
||||
return InvitedOrganisationUser.query.filter_by(organisation_id=organisation_id, id=invited_org_user_id).one()
|
||||
|
||||
|
||||
def get_invited_org_user_by_id(invited_org_user_id):
|
||||
return InvitedOrganizationUser.query.filter_by(id=invited_org_user_id).one()
|
||||
return InvitedOrganisationUser.query.filter_by(id=invited_org_user_id).one()
|
||||
|
||||
|
||||
def get_invited_org_users_for_organization(organization_id):
|
||||
return InvitedOrganizationUser.query.filter_by(
|
||||
organization_id=organization_id
|
||||
).all()
|
||||
def get_invited_org_users_for_organisation(organisation_id):
|
||||
return InvitedOrganisationUser.query.filter_by(organisation_id=organisation_id).all()
|
||||
|
||||
|
||||
def delete_org_invitations_created_more_than_two_days_ago():
|
||||
deleted = (
|
||||
db.session.query(InvitedOrganizationUser)
|
||||
.filter(
|
||||
InvitedOrganizationUser.created_at <= datetime.utcnow() - timedelta(days=2)
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
deleted = db.session.query(InvitedOrganisationUser).filter(
|
||||
InvitedOrganisationUser.created_at <= datetime.utcnow() - timedelta(days=2)
|
||||
).delete()
|
||||
db.session.commit()
|
||||
return deleted
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import db
|
||||
from app.enums import InvitedUserStatus
|
||||
from app.models import InvitedUser
|
||||
|
||||
|
||||
@@ -11,40 +10,20 @@ def save_invited_user(invited_user):
|
||||
|
||||
|
||||
def get_invited_user_by_service_and_id(service_id, invited_user_id):
|
||||
return InvitedUser.query.filter(
|
||||
InvitedUser.service_id == service_id,
|
||||
InvitedUser.id == invited_user_id,
|
||||
).one()
|
||||
|
||||
|
||||
def get_expired_invite_by_service_and_id(service_id, invited_user_id):
|
||||
return InvitedUser.query.filter(
|
||||
InvitedUser.service_id == service_id,
|
||||
InvitedUser.id == invited_user_id,
|
||||
InvitedUser.status == InvitedUserStatus.EXPIRED,
|
||||
).one()
|
||||
return InvitedUser.query.filter_by(service_id=service_id, id=invited_user_id).one()
|
||||
|
||||
|
||||
def get_invited_user_by_id(invited_user_id):
|
||||
return InvitedUser.query.filter(InvitedUser.id == invited_user_id).one()
|
||||
|
||||
|
||||
def get_expired_invited_users_for_service(service_id):
|
||||
return InvitedUser.query.filter(InvitedUser.service_id == service_id).all()
|
||||
return InvitedUser.query.filter_by(id=invited_user_id).one()
|
||||
|
||||
|
||||
def get_invited_users_for_service(service_id):
|
||||
return InvitedUser.query.filter(InvitedUser.service_id == service_id).all()
|
||||
return InvitedUser.query.filter_by(service_id=service_id).all()
|
||||
|
||||
|
||||
def expire_invitations_created_more_than_two_days_ago():
|
||||
expired = (
|
||||
db.session.query(InvitedUser)
|
||||
.filter(
|
||||
InvitedUser.created_at <= datetime.utcnow() - timedelta(days=2),
|
||||
InvitedUser.status.in_((InvitedUserStatus.PENDING,)),
|
||||
)
|
||||
.update({InvitedUser.status: InvitedUserStatus.EXPIRED})
|
||||
)
|
||||
def delete_invitations_created_more_than_two_days_ago():
|
||||
deleted = db.session.query(InvitedUser).filter(
|
||||
InvitedUser.created_at <= datetime.utcnow() - timedelta(days=2)
|
||||
).delete()
|
||||
db.session.commit()
|
||||
return expired
|
||||
return deleted
|
||||
|
||||
@@ -5,8 +5,10 @@ from flask import current_app
|
||||
from sqlalchemy import and_, asc, desc, func
|
||||
|
||||
from app import db
|
||||
from app.enums import JobStatus
|
||||
from app.models import (
|
||||
JOB_STATUS_FINISHED,
|
||||
JOB_STATUS_PENDING,
|
||||
JOB_STATUS_SCHEDULED,
|
||||
FactNotificationStatus,
|
||||
Job,
|
||||
Notification,
|
||||
@@ -17,27 +19,23 @@ from app.utils import midnight_n_days_ago
|
||||
|
||||
|
||||
def dao_get_notification_outcomes_for_job(service_id, job_id):
|
||||
notification_statuses = (
|
||||
db.session.query(
|
||||
func.count(Notification.status).label("count"), Notification.status
|
||||
)
|
||||
.filter(Notification.service_id == service_id, Notification.job_id == job_id)
|
||||
.group_by(Notification.status)
|
||||
.all()
|
||||
)
|
||||
notification_statuses = db.session.query(
|
||||
func.count(Notification.status).label('count'), Notification.status
|
||||
).filter(
|
||||
Notification.service_id == service_id,
|
||||
Notification.job_id == job_id
|
||||
).group_by(
|
||||
Notification.status
|
||||
).all()
|
||||
|
||||
if not notification_statuses:
|
||||
notification_statuses = (
|
||||
db.session.query(
|
||||
FactNotificationStatus.notification_count.label("count"),
|
||||
FactNotificationStatus.notification_status.label("status"),
|
||||
)
|
||||
.filter(
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
FactNotificationStatus.job_id == job_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
notification_statuses = db.session.query(
|
||||
FactNotificationStatus.notification_count.label('count'),
|
||||
FactNotificationStatus.notification_status.label('status')
|
||||
).filter(
|
||||
FactNotificationStatus.service_id == service_id,
|
||||
FactNotificationStatus.job_id == job_id
|
||||
).all()
|
||||
return notification_statuses
|
||||
|
||||
|
||||
@@ -45,10 +43,6 @@ def dao_get_job_by_service_id_and_job_id(service_id, job_id):
|
||||
return Job.query.filter_by(service_id=service_id, id=job_id).one()
|
||||
|
||||
|
||||
def dao_get_unfinished_jobs():
|
||||
return Job.query.filter(Job.processing_finished.is_(None)).all()
|
||||
|
||||
|
||||
def dao_get_jobs_by_service_id(
|
||||
service_id,
|
||||
*,
|
||||
@@ -56,37 +50,37 @@ def dao_get_jobs_by_service_id(
|
||||
page=1,
|
||||
page_size=50,
|
||||
statuses=None,
|
||||
contact_list_id=None,
|
||||
):
|
||||
query_filter = [
|
||||
Job.service_id == service_id,
|
||||
Job.original_file_name != current_app.config["TEST_MESSAGE_FILENAME"],
|
||||
Job.original_file_name != current_app.config["ONE_OFF_MESSAGE_FILENAME"],
|
||||
Job.original_file_name != current_app.config['TEST_MESSAGE_FILENAME'],
|
||||
Job.original_file_name != current_app.config['ONE_OFF_MESSAGE_FILENAME'],
|
||||
]
|
||||
if limit_days is not None:
|
||||
query_filter.append(Job.created_at >= midnight_n_days_ago(limit_days))
|
||||
if statuses is not None and statuses != [""]:
|
||||
query_filter.append(Job.job_status.in_(statuses))
|
||||
return (
|
||||
Job.query.filter(*query_filter)
|
||||
.order_by(Job.processing_started.desc(), Job.created_at.desc())
|
||||
if statuses is not None and statuses != ['']:
|
||||
query_filter.append(
|
||||
Job.job_status.in_(statuses)
|
||||
)
|
||||
if contact_list_id is not None:
|
||||
query_filter.append(Job.contact_list_id == contact_list_id)
|
||||
return Job.query \
|
||||
.filter(*query_filter) \
|
||||
.order_by(Job.processing_started.desc(), Job.created_at.desc()) \
|
||||
.paginate(page=page, per_page=page_size)
|
||||
)
|
||||
|
||||
|
||||
def dao_get_scheduled_job_stats(
|
||||
service_id,
|
||||
):
|
||||
return (
|
||||
db.session.query(
|
||||
func.count(Job.id),
|
||||
func.min(Job.scheduled_for),
|
||||
)
|
||||
.filter(
|
||||
Job.service_id == service_id,
|
||||
Job.job_status == JobStatus.SCHEDULED,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
return db.session.query(
|
||||
func.count(Job.id),
|
||||
func.min(Job.scheduled_for),
|
||||
).filter(
|
||||
Job.service_id == service_id,
|
||||
Job.job_status == JOB_STATUS_SCHEDULED,
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_job_by_id(job_id):
|
||||
@@ -107,18 +101,17 @@ def dao_set_scheduled_jobs_to_pending():
|
||||
the transaction so that if the task is run more than once concurrently, one task will block the other select
|
||||
from completing until it commits.
|
||||
"""
|
||||
jobs = (
|
||||
Job.query.filter(
|
||||
Job.job_status == JobStatus.SCHEDULED,
|
||||
Job.scheduled_for < datetime.utcnow(),
|
||||
)
|
||||
.order_by(asc(Job.scheduled_for))
|
||||
.with_for_update()
|
||||
jobs = Job.query \
|
||||
.filter(
|
||||
Job.job_status == JOB_STATUS_SCHEDULED,
|
||||
Job.scheduled_for < datetime.utcnow()
|
||||
) \
|
||||
.order_by(asc(Job.scheduled_for)) \
|
||||
.with_for_update() \
|
||||
.all()
|
||||
)
|
||||
|
||||
for job in jobs:
|
||||
job.job_status = JobStatus.PENDING
|
||||
job.job_status = JOB_STATUS_PENDING
|
||||
|
||||
db.session.add_all(jobs)
|
||||
db.session.commit()
|
||||
@@ -127,12 +120,14 @@ def dao_set_scheduled_jobs_to_pending():
|
||||
|
||||
|
||||
def dao_get_future_scheduled_job_by_id_and_service_id(job_id, service_id):
|
||||
return Job.query.filter(
|
||||
Job.service_id == service_id,
|
||||
Job.id == job_id,
|
||||
Job.job_status == JobStatus.SCHEDULED,
|
||||
Job.scheduled_for > datetime.utcnow(),
|
||||
).one()
|
||||
return Job.query \
|
||||
.filter(
|
||||
Job.service_id == service_id,
|
||||
Job.id == job_id,
|
||||
Job.job_status == JOB_STATUS_SCHEDULED,
|
||||
Job.scheduled_for > datetime.utcnow()
|
||||
) \
|
||||
.one()
|
||||
|
||||
|
||||
def dao_create_job(job):
|
||||
@@ -156,36 +151,24 @@ def dao_get_jobs_older_than_data_retention(notification_types):
|
||||
for f in flexible_data_retention:
|
||||
end_date = today - timedelta(days=f.days_of_retention)
|
||||
|
||||
jobs.extend(
|
||||
Job.query.join(Template)
|
||||
.filter(
|
||||
func.coalesce(Job.scheduled_for, Job.created_at) < end_date,
|
||||
Job.archived == False, # noqa
|
||||
Template.template_type == f.notification_type,
|
||||
Job.service_id == f.service_id,
|
||||
)
|
||||
.order_by(desc(Job.created_at))
|
||||
.all()
|
||||
)
|
||||
jobs.extend(Job.query.join(Template).filter(
|
||||
func.coalesce(Job.scheduled_for, Job.created_at) < end_date,
|
||||
Job.archived == False, # noqa
|
||||
Template.template_type == f.notification_type,
|
||||
Job.service_id == f.service_id
|
||||
).order_by(desc(Job.created_at)).all())
|
||||
|
||||
end_date = today - timedelta(days=7)
|
||||
for notification_type in notification_types:
|
||||
services_with_data_retention = [
|
||||
x.service_id
|
||||
for x in flexible_data_retention
|
||||
if x.notification_type == notification_type
|
||||
x.service_id for x in flexible_data_retention if x.notification_type == notification_type
|
||||
]
|
||||
jobs.extend(
|
||||
Job.query.join(Template)
|
||||
.filter(
|
||||
func.coalesce(Job.scheduled_for, Job.created_at) < end_date,
|
||||
Job.archived == False, # noqa
|
||||
Template.template_type == notification_type,
|
||||
Job.service_id.notin_(services_with_data_retention),
|
||||
)
|
||||
.order_by(desc(Job.created_at))
|
||||
.all()
|
||||
)
|
||||
jobs.extend(Job.query.join(Template).filter(
|
||||
func.coalesce(Job.scheduled_for, Job.created_at) < end_date,
|
||||
Job.archived == False, # noqa
|
||||
Template.template_type == notification_type,
|
||||
Job.service_id.notin_(services_with_data_retention)
|
||||
).order_by(desc(Job.created_at)).all())
|
||||
|
||||
return jobs
|
||||
|
||||
@@ -195,16 +178,18 @@ def find_jobs_with_missing_rows():
|
||||
# Using 20 minutes as a condition seems reasonable.
|
||||
ten_minutes_ago = datetime.utcnow() - timedelta(minutes=20)
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
jobs_with_rows_missing = (
|
||||
db.session.query(Job)
|
||||
.filter(
|
||||
Job.job_status == JobStatus.FINISHED,
|
||||
Job.processing_finished < ten_minutes_ago,
|
||||
Job.processing_finished > yesterday,
|
||||
Job.id == Notification.job_id,
|
||||
)
|
||||
.group_by(Job)
|
||||
.having(func.count(Notification.id) != Job.notification_count)
|
||||
jobs_with_rows_missing = db.session.query(
|
||||
Job
|
||||
).filter(
|
||||
Job.job_status == JOB_STATUS_FINISHED,
|
||||
Job.processing_finished < ten_minutes_ago,
|
||||
Job.processing_finished > yesterday,
|
||||
Job.id == Notification.job_id,
|
||||
|
||||
).group_by(
|
||||
Job
|
||||
).having(
|
||||
func.count(Notification.id) != Job.notification_count
|
||||
)
|
||||
|
||||
return jobs_with_rows_missing.all()
|
||||
@@ -212,20 +197,15 @@ def find_jobs_with_missing_rows():
|
||||
|
||||
def find_missing_row_for_job(job_id, job_size):
|
||||
expected_row_numbers = db.session.query(
|
||||
func.generate_series(0, job_size - 1).label("row")
|
||||
func.generate_series(0, job_size - 1).label('row')
|
||||
).subquery()
|
||||
|
||||
query = (
|
||||
db.session.query(
|
||||
Notification.job_row_number, expected_row_numbers.c.row.label("missing_row")
|
||||
)
|
||||
.outerjoin(
|
||||
Notification,
|
||||
and_(
|
||||
expected_row_numbers.c.row == Notification.job_row_number,
|
||||
Notification.job_id == job_id,
|
||||
),
|
||||
)
|
||||
.filter(Notification.job_row_number == None) # noqa
|
||||
query = db.session.query(
|
||||
Notification.job_row_number,
|
||||
expected_row_numbers.c.row.label('missing_row')
|
||||
).outerjoin(
|
||||
Notification, and_(expected_row_numbers.c.row == Notification.job_row_number, Notification.job_id == job_id)
|
||||
).filter(
|
||||
Notification.job_row_number == None # noqa
|
||||
)
|
||||
return query.all()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES
|
||||
from notifications_utils.international_billing_rates import (
|
||||
INTERNATIONAL_BILLING_RATES,
|
||||
)
|
||||
from notifications_utils.recipients import (
|
||||
InvalidEmailError,
|
||||
try_validate_and_format_phone_number,
|
||||
@@ -16,37 +18,46 @@ from werkzeug.datastructures import MultiDict
|
||||
|
||||
from app import create_uuid, db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.enums import KeyType, NotificationStatus, NotificationType
|
||||
from app.models import FactNotificationStatus, Notification, NotificationHistory
|
||||
from app.models import (
|
||||
EMAIL_TYPE,
|
||||
KEY_TYPE_TEST,
|
||||
NOTIFICATION_CREATED,
|
||||
NOTIFICATION_PENDING,
|
||||
NOTIFICATION_PENDING_VIRUS_CHECK,
|
||||
NOTIFICATION_PERMANENT_FAILURE,
|
||||
NOTIFICATION_SENDING,
|
||||
NOTIFICATION_SENT,
|
||||
NOTIFICATION_TEMPORARY_FAILURE,
|
||||
SMS_TYPE,
|
||||
FactNotificationStatus,
|
||||
Notification,
|
||||
NotificationHistory,
|
||||
)
|
||||
from app.utils import (
|
||||
escape_special_characters,
|
||||
get_midnight_in_utc,
|
||||
get_local_midnight_in_utc,
|
||||
midnight_n_days_ago,
|
||||
)
|
||||
|
||||
|
||||
def dao_get_last_date_template_was_used(template_id, service_id):
|
||||
last_date_from_notifications = (
|
||||
db.session.query(functions.max(Notification.created_at))
|
||||
.filter(
|
||||
Notification.service_id == service_id,
|
||||
Notification.template_id == template_id,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
last_date_from_notifications = db.session.query(
|
||||
functions.max(Notification.created_at)
|
||||
).filter(
|
||||
Notification.service_id == service_id,
|
||||
Notification.template_id == template_id,
|
||||
Notification.key_type != KEY_TYPE_TEST
|
||||
).scalar()
|
||||
|
||||
if last_date_from_notifications:
|
||||
return last_date_from_notifications
|
||||
|
||||
last_date = (
|
||||
db.session.query(functions.max(FactNotificationStatus.local_date))
|
||||
.filter(
|
||||
FactNotificationStatus.template_id == template_id,
|
||||
FactNotificationStatus.key_type != KeyType.TEST,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
last_date = db.session.query(
|
||||
functions.max(FactNotificationStatus.local_date)
|
||||
).filter(
|
||||
FactNotificationStatus.template_id == template_id,
|
||||
FactNotificationStatus.key_type != KEY_TYPE_TEST
|
||||
).scalar()
|
||||
|
||||
return last_date
|
||||
|
||||
@@ -57,144 +68,103 @@ def dao_create_notification(notification):
|
||||
# need to populate defaulted fields before we create the notification history object
|
||||
notification.id = create_uuid()
|
||||
if not notification.status:
|
||||
notification.status = NotificationStatus.CREATED
|
||||
notification.status = NOTIFICATION_CREATED
|
||||
|
||||
# notify-api-749 do not write to db
|
||||
# if we have a verify_code we know this is the authentication notification at login time
|
||||
# and not csv (containing PII) provided by the user, so allow verify_code to continue to exist
|
||||
if "verify_code" in str(notification.personalisation):
|
||||
pass
|
||||
else:
|
||||
notification.personalisation = ""
|
||||
|
||||
# notify-api-742 remove phone numbers from db
|
||||
notification.to = "1"
|
||||
notification.normalised_to = "1"
|
||||
db.session.add(notification)
|
||||
|
||||
|
||||
def country_records_delivery(phone_prefix):
|
||||
dlr = INTERNATIONAL_BILLING_RATES[phone_prefix]["attributes"]["dlr"]
|
||||
return dlr and dlr.lower() == "yes"
|
||||
dlr = INTERNATIONAL_BILLING_RATES[phone_prefix]['attributes']['dlr']
|
||||
return dlr and dlr.lower() == 'yes'
|
||||
|
||||
|
||||
def _decide_permanent_temporary_failure(current_status, status):
|
||||
# If we go from pending to delivered we need to set failure type as temporary-failure
|
||||
if (
|
||||
current_status == NotificationStatus.PENDING
|
||||
and status == NotificationStatus.PERMANENT_FAILURE
|
||||
):
|
||||
status = NotificationStatus.TEMPORARY_FAILURE
|
||||
if current_status == NOTIFICATION_PENDING and status == NOTIFICATION_PERMANENT_FAILURE:
|
||||
status = NOTIFICATION_TEMPORARY_FAILURE
|
||||
return status
|
||||
|
||||
|
||||
def _update_notification_status(
|
||||
notification, status, provider_response=None, carrier=None
|
||||
):
|
||||
status = _decide_permanent_temporary_failure(
|
||||
current_status=notification.status, status=status
|
||||
)
|
||||
def _update_notification_status(notification, status, provider_response=None):
|
||||
status = _decide_permanent_temporary_failure(current_status=notification.status, status=status)
|
||||
notification.status = status
|
||||
notification.sent_at = datetime.utcnow()
|
||||
if provider_response:
|
||||
notification.provider_response = provider_response
|
||||
if carrier:
|
||||
notification.carrier = carrier
|
||||
dao_update_notification(notification)
|
||||
return notification
|
||||
|
||||
|
||||
@autocommit
|
||||
def update_notification_status_by_id(
|
||||
notification_id, status, sent_by=None, provider_response=None, carrier=None
|
||||
):
|
||||
notification = (
|
||||
Notification.query.with_for_update()
|
||||
.filter(Notification.id == notification_id)
|
||||
.first()
|
||||
)
|
||||
def update_notification_status_by_id(notification_id, status, sent_by=None):
|
||||
notification = Notification.query.with_for_update().filter(Notification.id == notification_id).first()
|
||||
|
||||
if not notification:
|
||||
current_app.logger.info(
|
||||
"notification not found for id {} (update to status {})".format(
|
||||
notification_id, status
|
||||
)
|
||||
)
|
||||
current_app.logger.info('notification not found for id {} (update to status {})'.format(
|
||||
notification_id,
|
||||
status
|
||||
))
|
||||
return None
|
||||
|
||||
if notification.status not in {
|
||||
NotificationStatus.CREATED,
|
||||
NotificationStatus.SENDING,
|
||||
NotificationStatus.PENDING,
|
||||
NotificationStatus.SENT,
|
||||
NotificationStatus.PENDING_VIRUS_CHECK,
|
||||
NOTIFICATION_CREATED,
|
||||
NOTIFICATION_SENDING,
|
||||
NOTIFICATION_PENDING,
|
||||
NOTIFICATION_SENT,
|
||||
NOTIFICATION_PENDING_VIRUS_CHECK
|
||||
}:
|
||||
_duplicate_update_warning(notification, status)
|
||||
return None
|
||||
|
||||
if (
|
||||
notification.notification_type == NotificationType.SMS
|
||||
notification.notification_type == SMS_TYPE
|
||||
and notification.international
|
||||
and not country_records_delivery(notification.phone_prefix)
|
||||
):
|
||||
return None
|
||||
if provider_response:
|
||||
notification.provider_response = provider_response
|
||||
if carrier:
|
||||
notification.carrier = carrier
|
||||
if not notification.sent_by and sent_by:
|
||||
notification.sent_by = sent_by
|
||||
return _update_notification_status(
|
||||
notification=notification,
|
||||
status=status,
|
||||
provider_response=notification.provider_response,
|
||||
carrier=notification.carrier,
|
||||
status=status
|
||||
)
|
||||
|
||||
|
||||
@autocommit
|
||||
def update_notification_status_by_reference(reference, status):
|
||||
# this is used to update emails
|
||||
notification = Notification.query.filter(
|
||||
Notification.reference == reference
|
||||
).first()
|
||||
notification = Notification.query.filter(Notification.reference == reference).first()
|
||||
|
||||
if not notification:
|
||||
current_app.logger.error(
|
||||
"notification not found for reference {} (update to {})".format(
|
||||
reference, status
|
||||
)
|
||||
)
|
||||
current_app.logger.error('notification not found for reference {} (update to {})'.format(reference, status))
|
||||
return None
|
||||
|
||||
if notification.status not in {
|
||||
NotificationStatus.SENDING,
|
||||
NotificationStatus.PENDING,
|
||||
NOTIFICATION_SENDING,
|
||||
NOTIFICATION_PENDING
|
||||
}:
|
||||
_duplicate_update_warning(notification, status)
|
||||
return None
|
||||
|
||||
return _update_notification_status(notification=notification, status=status)
|
||||
return _update_notification_status(
|
||||
notification=notification,
|
||||
status=status
|
||||
)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_update_notification(notification):
|
||||
notification.updated_at = datetime.utcnow()
|
||||
# notify-api-742 remove phone numbers from db
|
||||
notification.to = "1"
|
||||
notification.normalised_to = "1"
|
||||
db.session.add(notification)
|
||||
|
||||
|
||||
def get_notifications_for_job(
|
||||
service_id, job_id, filter_dict=None, page=1, page_size=None
|
||||
):
|
||||
def get_notifications_for_job(service_id, job_id, filter_dict=None, page=1, page_size=None):
|
||||
if page_size is None:
|
||||
page_size = current_app.config["PAGE_SIZE"]
|
||||
page_size = current_app.config['PAGE_SIZE']
|
||||
query = Notification.query.filter_by(service_id=service_id, job_id=job_id)
|
||||
query = _filter_query(query, filter_dict)
|
||||
return query.order_by(asc(Notification.job_row_number)).paginate(
|
||||
page=page, per_page=page_size
|
||||
page=page,
|
||||
per_page=page_size
|
||||
)
|
||||
|
||||
|
||||
@@ -202,28 +172,12 @@ def dao_get_notification_count_for_job_id(*, job_id):
|
||||
return Notification.query.filter_by(job_id=job_id).count()
|
||||
|
||||
|
||||
def dao_get_notification_count_for_service(*, service_id):
|
||||
notification_count = Notification.query.filter_by(service_id=service_id).count()
|
||||
return notification_count
|
||||
|
||||
|
||||
def dao_get_failed_notification_count():
|
||||
failed_count = Notification.query.filter_by(
|
||||
status=NotificationStatus.FAILED
|
||||
).count()
|
||||
return failed_count
|
||||
|
||||
|
||||
def get_notification_with_personalisation(service_id, notification_id, key_type):
|
||||
filter_dict = {"service_id": service_id, "id": notification_id}
|
||||
filter_dict = {'service_id': service_id, 'id': notification_id}
|
||||
if key_type:
|
||||
filter_dict["key_type"] = key_type
|
||||
filter_dict['key_type'] = key_type
|
||||
|
||||
return (
|
||||
Notification.query.filter_by(**filter_dict)
|
||||
.options(joinedload("template"))
|
||||
.one()
|
||||
)
|
||||
return Notification.query.filter_by(**filter_dict).options(joinedload('template')).one()
|
||||
|
||||
|
||||
def get_notification_by_id(notification_id, service_id=None, _raise=False):
|
||||
@@ -238,23 +192,23 @@ def get_notification_by_id(notification_id, service_id=None, _raise=False):
|
||||
|
||||
|
||||
def get_notifications_for_service(
|
||||
service_id,
|
||||
filter_dict=None,
|
||||
page=1,
|
||||
page_size=None,
|
||||
count_pages=True,
|
||||
limit_days=None,
|
||||
key_type=None,
|
||||
personalisation=False,
|
||||
include_jobs=False,
|
||||
include_from_test_key=False,
|
||||
older_than=None,
|
||||
client_reference=None,
|
||||
include_one_off=True,
|
||||
error_out=True,
|
||||
service_id,
|
||||
filter_dict=None,
|
||||
page=1,
|
||||
page_size=None,
|
||||
count_pages=True,
|
||||
limit_days=None,
|
||||
key_type=None,
|
||||
personalisation=False,
|
||||
include_jobs=False,
|
||||
include_from_test_key=False,
|
||||
older_than=None,
|
||||
client_reference=None,
|
||||
include_one_off=True,
|
||||
error_out=True
|
||||
):
|
||||
if page_size is None:
|
||||
page_size = current_app.config["PAGE_SIZE"]
|
||||
page_size = current_app.config['PAGE_SIZE']
|
||||
|
||||
filters = [Notification.service_id == service_id]
|
||||
|
||||
@@ -262,11 +216,8 @@ def get_notifications_for_service(
|
||||
filters.append(Notification.created_at >= midnight_n_days_ago(limit_days))
|
||||
|
||||
if older_than is not None:
|
||||
older_than_created_at = (
|
||||
db.session.query(Notification.created_at)
|
||||
.filter(Notification.id == older_than)
|
||||
.as_scalar()
|
||||
)
|
||||
older_than_created_at = db.session.query(
|
||||
Notification.created_at).filter(Notification.id == older_than).as_scalar()
|
||||
filters.append(Notification.created_at < older_than_created_at)
|
||||
|
||||
if not include_jobs:
|
||||
@@ -278,7 +229,7 @@ def get_notifications_for_service(
|
||||
if key_type is not None:
|
||||
filters.append(Notification.key_type == key_type)
|
||||
elif not include_from_test_key:
|
||||
filters.append(Notification.key_type != KeyType.TEST)
|
||||
filters.append(Notification.key_type != KEY_TYPE_TEST)
|
||||
|
||||
if client_reference is not None:
|
||||
filters.append(Notification.client_reference == client_reference)
|
||||
@@ -286,7 +237,9 @@ def get_notifications_for_service(
|
||||
query = Notification.query.filter(*filters)
|
||||
query = _filter_query(query, filter_dict)
|
||||
if personalisation:
|
||||
query = query.options(joinedload("template"))
|
||||
query = query.options(
|
||||
joinedload('template')
|
||||
)
|
||||
|
||||
return query.order_by(desc(Notification.created_at)).paginate(
|
||||
page=page,
|
||||
@@ -303,37 +256,19 @@ def _filter_query(query, filter_dict=None):
|
||||
multidict = MultiDict(filter_dict)
|
||||
|
||||
# filter by status
|
||||
statuses = multidict.getlist("status")
|
||||
|
||||
statuses = multidict.getlist('status')
|
||||
if statuses:
|
||||
statuses = Notification.substitute_status(statuses)
|
||||
query = query.filter(Notification.status.in_(statuses))
|
||||
|
||||
# filter by template
|
||||
template_types = multidict.getlist("template_type")
|
||||
template_types = multidict.getlist('template_type')
|
||||
if template_types:
|
||||
query = query.filter(Notification.notification_type.in_(template_types))
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def sanitize_successful_notification_by_id(notification_id, carrier, provider_response):
|
||||
update_query = """
|
||||
update notifications set provider_response=:response, carrier=:carrier,
|
||||
notification_status='delivered', sent_at=:sent_at, "to"='1', normalised_to='1'
|
||||
where id=:notification_id
|
||||
"""
|
||||
|
||||
input_params = {
|
||||
"notification_id": notification_id,
|
||||
"carrier": carrier,
|
||||
"response": provider_response,
|
||||
"sent_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
db.session.execute(update_query, input_params)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@autocommit
|
||||
def insert_notification_history_delete_notifications(
|
||||
notification_type, service_id, timestamp_to_delete_backwards_from, qry_limit=50000
|
||||
@@ -383,14 +318,12 @@ def insert_notification_history_delete_notifications(
|
||||
"service_id": service_id,
|
||||
"notification_type": notification_type,
|
||||
"timestamp_to_delete_backwards_from": timestamp_to_delete_backwards_from,
|
||||
"qry_limit": qry_limit,
|
||||
"qry_limit": qry_limit
|
||||
}
|
||||
|
||||
db.session.execute(select_into_temp_table, input_params)
|
||||
|
||||
result = db.session.execute("select count(*) from NOTIFICATION_ARCHIVE").fetchone()[
|
||||
0
|
||||
]
|
||||
result = db.session.execute("select count(*) from NOTIFICATION_ARCHIVE").fetchone()[0]
|
||||
|
||||
db.session.execute(insert_query)
|
||||
|
||||
@@ -400,7 +333,10 @@ def insert_notification_history_delete_notifications(
|
||||
|
||||
|
||||
def move_notifications_to_notification_history(
|
||||
notification_type, service_id, timestamp_to_delete_backwards_from, qry_limit=50000
|
||||
notification_type,
|
||||
service_id,
|
||||
timestamp_to_delete_backwards_from,
|
||||
qry_limit=50000
|
||||
):
|
||||
deleted = 0
|
||||
delete_count_per_call = 1
|
||||
@@ -409,7 +345,7 @@ def move_notifications_to_notification_history(
|
||||
notification_type=notification_type,
|
||||
service_id=service_id,
|
||||
timestamp_to_delete_backwards_from=timestamp_to_delete_backwards_from,
|
||||
qry_limit=qry_limit,
|
||||
qry_limit=qry_limit
|
||||
)
|
||||
deleted += delete_count_per_call
|
||||
|
||||
@@ -418,7 +354,7 @@ def move_notifications_to_notification_history(
|
||||
Notification.notification_type == notification_type,
|
||||
Notification.service_id == service_id,
|
||||
Notification.created_at < timestamp_to_delete_backwards_from,
|
||||
Notification.key_type == KeyType.TEST,
|
||||
Notification.key_type == KEY_TYPE_TEST
|
||||
).delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
@@ -427,9 +363,9 @@ def move_notifications_to_notification_history(
|
||||
|
||||
@autocommit
|
||||
def dao_delete_notifications_by_id(notification_id):
|
||||
db.session.query(Notification).filter(Notification.id == notification_id).delete(
|
||||
synchronize_session="fetch"
|
||||
)
|
||||
db.session.query(Notification).filter(
|
||||
Notification.id == notification_id
|
||||
).delete(synchronize_session='fetch')
|
||||
|
||||
|
||||
def dao_timeout_notifications(cutoff_time, limit=100000):
|
||||
@@ -438,25 +374,20 @@ def dao_timeout_notifications(cutoff_time, limit=100000):
|
||||
if they're still sending from before the specified cutoff_time.
|
||||
"""
|
||||
updated_at = datetime.utcnow()
|
||||
current_statuses = [NotificationStatus.SENDING, NotificationStatus.PENDING]
|
||||
new_status = NotificationStatus.TEMPORARY_FAILURE
|
||||
current_statuses = [NOTIFICATION_SENDING, NOTIFICATION_PENDING]
|
||||
new_status = NOTIFICATION_TEMPORARY_FAILURE
|
||||
|
||||
notifications = (
|
||||
Notification.query.filter(
|
||||
Notification.created_at < cutoff_time,
|
||||
Notification.status.in_(current_statuses),
|
||||
Notification.notification_type.in_(
|
||||
[NotificationType.SMS, NotificationType.EMAIL]
|
||||
),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
notifications = Notification.query.filter(
|
||||
Notification.created_at < cutoff_time,
|
||||
Notification.status.in_(current_statuses),
|
||||
Notification.notification_type.in_([SMS_TYPE, EMAIL_TYPE])
|
||||
).limit(limit).all()
|
||||
|
||||
Notification.query.filter(
|
||||
Notification.id.in_([n.id for n in notifications]),
|
||||
).update(
|
||||
{"status": new_status, "updated_at": updated_at}, synchronize_session=False
|
||||
{'status': new_status, 'updated_at': updated_at},
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
@@ -467,13 +398,19 @@ def dao_timeout_notifications(cutoff_time, limit=100000):
|
||||
def dao_update_notifications_by_reference(references, update_dict):
|
||||
updated_count = Notification.query.filter(
|
||||
Notification.reference.in_(references)
|
||||
).update(update_dict, synchronize_session=False)
|
||||
).update(
|
||||
update_dict,
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
updated_history_count = 0
|
||||
if updated_count != len(references):
|
||||
updated_history_count = NotificationHistory.query.filter(
|
||||
NotificationHistory.reference.in_(references)
|
||||
).update(update_dict, synchronize_session=False)
|
||||
).update(
|
||||
update_dict,
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
return updated_count, updated_history_count
|
||||
|
||||
@@ -487,15 +424,16 @@ def dao_get_notifications_by_recipient_or_reference(
|
||||
page_size=None,
|
||||
error_out=True,
|
||||
):
|
||||
if notification_type == NotificationType.SMS:
|
||||
|
||||
if notification_type == SMS_TYPE:
|
||||
normalised = try_validate_and_format_phone_number(search_term)
|
||||
|
||||
for character in {"(", ")", " ", "-"}:
|
||||
normalised = normalised.replace(character, "")
|
||||
for character in {'(', ')', ' ', '-'}:
|
||||
normalised = normalised.replace(character, '')
|
||||
|
||||
normalised = normalised.lstrip("+0")
|
||||
normalised = normalised.lstrip('+0')
|
||||
|
||||
elif notification_type == NotificationType.EMAIL:
|
||||
elif notification_type == EMAIL_TYPE:
|
||||
try:
|
||||
normalised = validate_and_format_email_address(search_term)
|
||||
except InvalidEmailError:
|
||||
@@ -506,11 +444,11 @@ def dao_get_notifications_by_recipient_or_reference(
|
||||
# happen if a user doesn’t have permission to see the dashboard)
|
||||
# because email addresses and phone numbers will never be stored
|
||||
# with spaces either.
|
||||
normalised = "".join(search_term.split()).lower()
|
||||
normalised = ''.join(search_term.split()).lower()
|
||||
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Notification type must be {NotificationType.EMAIL}, {NotificationType.SMS}, or None"
|
||||
f'Notification type must be {EMAIL_TYPE}, {SMS_TYPE}, or None'
|
||||
)
|
||||
|
||||
normalised = escape_special_characters(normalised)
|
||||
@@ -522,7 +460,7 @@ def dao_get_notifications_by_recipient_or_reference(
|
||||
Notification.normalised_to.like("%{}%".format(normalised)),
|
||||
Notification.client_reference.ilike("%{}%".format(search_term)),
|
||||
),
|
||||
Notification.key_type != KeyType.TEST,
|
||||
Notification.key_type != KEY_TYPE_TEST,
|
||||
]
|
||||
|
||||
if statuses:
|
||||
@@ -530,17 +468,17 @@ def dao_get_notifications_by_recipient_or_reference(
|
||||
if notification_type:
|
||||
filters.append(Notification.notification_type == notification_type)
|
||||
|
||||
results = (
|
||||
db.session.query(Notification)
|
||||
.filter(*filters)
|
||||
.order_by(desc(Notification.created_at))
|
||||
results = db.session.query(Notification)\
|
||||
.filter(*filters)\
|
||||
.order_by(desc(Notification.created_at))\
|
||||
.paginate(page=page, per_page=page_size, count=False, error_out=error_out)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def dao_get_notification_by_reference(reference):
|
||||
return Notification.query.filter(Notification.reference == reference).one()
|
||||
return Notification.query.filter(
|
||||
Notification.reference == reference
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_notification_history_by_reference(reference):
|
||||
@@ -549,9 +487,7 @@ def dao_get_notification_history_by_reference(reference):
|
||||
# Otherwise we could just search for the NotificationHistory object
|
||||
return Notification.query.filter(Notification.reference == reference).one()
|
||||
except NoResultFound:
|
||||
return NotificationHistory.query.filter(
|
||||
NotificationHistory.reference == reference
|
||||
).one()
|
||||
return NotificationHistory.query.filter(NotificationHistory.reference == reference).one()
|
||||
|
||||
|
||||
def dao_get_notifications_processing_time_stats(start_date, end_date):
|
||||
@@ -569,47 +505,44 @@ def dao_get_notifications_processing_time_stats(start_date, end_date):
|
||||
api_key_id IS NOT NULL AND
|
||||
key_type != 'test';
|
||||
"""
|
||||
under_10_secs = Notification.sent_at - Notification.created_at <= timedelta(
|
||||
seconds=10
|
||||
)
|
||||
sum_column = functions.coalesce(
|
||||
functions.sum(case([(under_10_secs, 1)], else_=0)), 0
|
||||
)
|
||||
under_10_secs = Notification.sent_at - Notification.created_at <= timedelta(seconds=10)
|
||||
sum_column = functions.coalesce(functions.sum(
|
||||
case(
|
||||
[
|
||||
(under_10_secs, 1)
|
||||
],
|
||||
else_=0
|
||||
)
|
||||
), 0)
|
||||
|
||||
return (
|
||||
db.session.query(
|
||||
func.count(Notification.id).label("messages_total"),
|
||||
sum_column.label("messages_within_10_secs"),
|
||||
)
|
||||
.filter(
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at < end_date,
|
||||
Notification.api_key_id.isnot(None),
|
||||
Notification.key_type != KeyType.TEST,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
return db.session.query(
|
||||
func.count(Notification.id).label('messages_total'),
|
||||
sum_column.label('messages_within_10_secs')
|
||||
).filter(
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at < end_date,
|
||||
Notification.api_key_id.isnot(None),
|
||||
Notification.key_type != KEY_TYPE_TEST,
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_last_notification_added_for_job_id(job_id):
|
||||
last_notification_added = (
|
||||
Notification.query.filter(Notification.job_id == job_id)
|
||||
.order_by(Notification.job_row_number.desc())
|
||||
.first()
|
||||
)
|
||||
last_notification_added = Notification.query.filter(
|
||||
Notification.job_id == job_id
|
||||
).order_by(
|
||||
Notification.job_row_number.desc()
|
||||
).first()
|
||||
|
||||
return last_notification_added
|
||||
|
||||
|
||||
def notifications_not_yet_sent(should_be_sending_after_seconds, notification_type):
|
||||
older_than_date = datetime.utcnow() - timedelta(
|
||||
seconds=should_be_sending_after_seconds
|
||||
)
|
||||
older_than_date = datetime.utcnow() - timedelta(seconds=should_be_sending_after_seconds)
|
||||
|
||||
notifications = Notification.query.filter(
|
||||
Notification.created_at <= older_than_date,
|
||||
Notification.notification_type == notification_type,
|
||||
Notification.status == NotificationStatus.CREATED,
|
||||
Notification.status == NOTIFICATION_CREATED
|
||||
).all()
|
||||
return notifications
|
||||
|
||||
@@ -617,19 +550,18 @@ def notifications_not_yet_sent(should_be_sending_after_seconds, notification_typ
|
||||
def _duplicate_update_warning(notification, status):
|
||||
current_app.logger.info(
|
||||
(
|
||||
"Duplicate callback received for service {service_id}. "
|
||||
"Notification ID {id} with type {type} sent by {sent_by}. "
|
||||
"New status was {new_status}, current status is {old_status}. "
|
||||
"This happened {time_diff} after being first set."
|
||||
'Duplicate callback received for service {service_id}. '
|
||||
'Notification ID {id} with type {type} sent by {sent_by}. '
|
||||
'New status was {new_status}, current status is {old_status}. '
|
||||
'This happened {time_diff} after being first set.'
|
||||
).format(
|
||||
id=notification.id,
|
||||
old_status=notification.status,
|
||||
new_status=status,
|
||||
time_diff=datetime.utcnow()
|
||||
- (notification.updated_at or notification.created_at),
|
||||
time_diff=datetime.utcnow() - (notification.updated_at or notification.created_at),
|
||||
type=notification.notification_type,
|
||||
sent_by=notification.sent_by,
|
||||
service_id=notification.service_id,
|
||||
service_id=notification.service_id
|
||||
)
|
||||
)
|
||||
|
||||
@@ -637,21 +569,21 @@ def _duplicate_update_warning(notification, status):
|
||||
def get_service_ids_with_notifications_before(notification_type, timestamp):
|
||||
return {
|
||||
row.service_id
|
||||
for row in db.session.query(Notification.service_id)
|
||||
.filter(
|
||||
for row in db.session.query(
|
||||
Notification.service_id
|
||||
).filter(
|
||||
Notification.notification_type == notification_type,
|
||||
Notification.created_at < timestamp,
|
||||
)
|
||||
.distinct()
|
||||
Notification.created_at < timestamp
|
||||
).distinct()
|
||||
}
|
||||
|
||||
|
||||
def get_service_ids_with_notifications_on_date(notification_type, date):
|
||||
start_date = get_midnight_in_utc(date)
|
||||
end_date = get_midnight_in_utc(date + timedelta(days=1))
|
||||
start_date = get_local_midnight_in_utc(date)
|
||||
end_date = get_local_midnight_in_utc(date + timedelta(days=1))
|
||||
|
||||
notification_table_query = db.session.query(
|
||||
Notification.service_id.label("service_id")
|
||||
Notification.service_id.label('service_id')
|
||||
).filter(
|
||||
Notification.notification_type == notification_type,
|
||||
# using >= + < is much more efficient than date(created_at)
|
||||
@@ -662,15 +594,14 @@ def get_service_ids_with_notifications_on_date(notification_type, date):
|
||||
# Looking at this table is more efficient for historical notifications,
|
||||
# provided the task to populate it has run before they were archived.
|
||||
ft_status_table_query = db.session.query(
|
||||
FactNotificationStatus.service_id.label("service_id")
|
||||
FactNotificationStatus.service_id.label('service_id')
|
||||
).filter(
|
||||
FactNotificationStatus.notification_type == notification_type,
|
||||
FactNotificationStatus.local_date == date,
|
||||
)
|
||||
|
||||
return {
|
||||
row.service_id
|
||||
for row in db.session.query(
|
||||
union(notification_table_query, ft_status_table_query).subquery()
|
||||
).distinct()
|
||||
row.service_id for row in db.session.query(union(
|
||||
notification_table_query, ft_status_table_query
|
||||
).subquery()).distinct()
|
||||
}
|
||||
|
||||
141
app/dao/organisation_dao.py
Normal file
141
app/dao/organisation_dao.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import VersionOptions, autocommit, version_class
|
||||
from app.models import Domain, Organisation, Service, User
|
||||
|
||||
|
||||
def dao_get_organisations():
|
||||
return Organisation.query.order_by(
|
||||
Organisation.active.desc(), Organisation.name.asc()
|
||||
).all()
|
||||
|
||||
|
||||
def dao_count_organisations_with_live_services():
|
||||
return db.session.query(Organisation.id).join(Organisation.services).filter(
|
||||
Service.active.is_(True),
|
||||
Service.restricted.is_(False),
|
||||
Service.count_as_live.is_(True),
|
||||
).distinct().count()
|
||||
|
||||
|
||||
def dao_get_organisation_services(organisation_id):
|
||||
return Organisation.query.filter_by(
|
||||
id=organisation_id
|
||||
).one().services
|
||||
|
||||
|
||||
def dao_get_organisation_live_services(organisation_id):
|
||||
return Service.query.filter_by(
|
||||
organisation_id=organisation_id,
|
||||
restricted=False
|
||||
).all()
|
||||
|
||||
|
||||
def dao_get_organisation_by_id(organisation_id):
|
||||
return Organisation.query.filter_by(id=organisation_id).one()
|
||||
|
||||
|
||||
def dao_get_organisation_by_email_address(email_address):
|
||||
|
||||
email_address = email_address.lower().replace('.gsi.gov.uk', '.gov.uk')
|
||||
|
||||
for domain in Domain.query.order_by(func.char_length(Domain.domain).desc()).all():
|
||||
|
||||
if (
|
||||
email_address.endswith("@{}".format(domain.domain)) or
|
||||
email_address.endswith(".{}".format(domain.domain))
|
||||
):
|
||||
return Organisation.query.filter_by(id=domain.organisation_id).one()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def dao_get_organisation_by_service_id(service_id):
|
||||
return Organisation.query.join(Organisation.services).filter_by(id=service_id).first()
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_create_organisation(organisation):
|
||||
db.session.add(organisation)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_update_organisation(organisation_id, **kwargs):
|
||||
|
||||
domains = kwargs.pop('domains', None)
|
||||
|
||||
num_updated = Organisation.query.filter_by(id=organisation_id).update(
|
||||
kwargs
|
||||
)
|
||||
|
||||
if isinstance(domains, list):
|
||||
|
||||
Domain.query.filter_by(organisation_id=organisation_id).delete()
|
||||
|
||||
db.session.bulk_save_objects([
|
||||
Domain(domain=domain.lower(), organisation_id=organisation_id)
|
||||
for domain in domains
|
||||
])
|
||||
|
||||
organisation = Organisation.query.get(organisation_id)
|
||||
|
||||
if 'organisation_type' in kwargs:
|
||||
_update_organisation_services(organisation, 'organisation_type', only_where_none=False)
|
||||
|
||||
if 'crown' in kwargs:
|
||||
_update_organisation_services(organisation, 'crown', only_where_none=False)
|
||||
|
||||
if 'email_branding_id' in kwargs:
|
||||
_update_organisation_services(organisation, 'email_branding')
|
||||
|
||||
return num_updated
|
||||
|
||||
|
||||
@version_class(
|
||||
VersionOptions(Service, must_write_history=False),
|
||||
)
|
||||
def _update_organisation_services(organisation, attribute, only_where_none=True):
|
||||
for service in organisation.services:
|
||||
if getattr(service, attribute) is None or not only_where_none:
|
||||
setattr(service, attribute, getattr(organisation, attribute))
|
||||
db.session.add(service)
|
||||
|
||||
|
||||
@autocommit
|
||||
@version_class(Service)
|
||||
def dao_add_service_to_organisation(service, organisation_id):
|
||||
organisation = Organisation.query.filter_by(
|
||||
id=organisation_id
|
||||
).one()
|
||||
|
||||
service.organisation_id = organisation_id
|
||||
service.organisation_type = organisation.organisation_type
|
||||
service.crown = organisation.crown
|
||||
|
||||
db.session.add(service)
|
||||
|
||||
|
||||
def dao_get_users_for_organisation(organisation_id):
|
||||
return db.session.query(
|
||||
User
|
||||
).join(
|
||||
User.organisations
|
||||
).filter(
|
||||
Organisation.id == organisation_id,
|
||||
User.state == 'active'
|
||||
).order_by(User.created_at).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_add_user_to_organisation(organisation_id, user_id):
|
||||
organisation = dao_get_organisation_by_id(organisation_id)
|
||||
user = User.query.filter_by(id=user_id).one()
|
||||
user.organisations.append(organisation)
|
||||
db.session.add(organisation)
|
||||
return user
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_remove_user_from_organisation(organisation, user):
|
||||
organisation.users.remove(user)
|
||||
@@ -1,133 +0,0 @@
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import VersionOptions, autocommit, version_class
|
||||
from app.models import Domain, Organization, Service, User
|
||||
|
||||
|
||||
def dao_get_organizations():
|
||||
return Organization.query.order_by(
|
||||
Organization.active.desc(), Organization.name.asc()
|
||||
).all()
|
||||
|
||||
|
||||
def dao_count_organizations_with_live_services():
|
||||
return (
|
||||
db.session.query(Organization.id)
|
||||
.join(Organization.services)
|
||||
.filter(
|
||||
Service.active.is_(True),
|
||||
Service.restricted.is_(False),
|
||||
Service.count_as_live.is_(True),
|
||||
)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def dao_get_organization_services(organization_id):
|
||||
return Organization.query.filter_by(id=organization_id).one().services
|
||||
|
||||
|
||||
def dao_get_organization_live_services(organization_id):
|
||||
return Service.query.filter_by(
|
||||
organization_id=organization_id, restricted=False
|
||||
).all()
|
||||
|
||||
|
||||
def dao_get_organization_by_id(organization_id):
|
||||
return Organization.query.filter_by(id=organization_id).one()
|
||||
|
||||
|
||||
def dao_get_organization_by_email_address(email_address):
|
||||
email_address = email_address.lower().replace(".gsi.gov.uk", ".gov.uk")
|
||||
|
||||
for domain in Domain.query.order_by(func.char_length(Domain.domain).desc()).all():
|
||||
if email_address.endswith(
|
||||
"@{}".format(domain.domain)
|
||||
) or email_address.endswith(".{}".format(domain.domain)):
|
||||
return Organization.query.filter_by(id=domain.organization_id).one()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def dao_get_organization_by_service_id(service_id):
|
||||
return (
|
||||
Organization.query.join(Organization.services).filter_by(id=service_id).first()
|
||||
)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_create_organization(organization):
|
||||
db.session.add(organization)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_update_organization(organization_id, **kwargs):
|
||||
domains = kwargs.pop("domains", None)
|
||||
num_updated = Organization.query.filter_by(id=organization_id).update(kwargs)
|
||||
|
||||
if isinstance(domains, list):
|
||||
Domain.query.filter_by(organization_id=organization_id).delete()
|
||||
db.session.bulk_save_objects(
|
||||
[
|
||||
Domain(domain=domain.lower(), organization_id=organization_id)
|
||||
for domain in domains
|
||||
]
|
||||
)
|
||||
|
||||
organization = Organization.query.get(organization_id)
|
||||
if "organization_type" in kwargs:
|
||||
_update_organization_services(
|
||||
organization, "organization_type", only_where_none=False
|
||||
)
|
||||
|
||||
if "email_branding_id" in kwargs:
|
||||
_update_organization_services(organization, "email_branding")
|
||||
|
||||
return num_updated
|
||||
|
||||
|
||||
@version_class(
|
||||
VersionOptions(Service, must_write_history=False),
|
||||
)
|
||||
def _update_organization_services(organization, attribute, only_where_none=True):
|
||||
for service in organization.services:
|
||||
if getattr(service, attribute) is None or not only_where_none:
|
||||
setattr(service, attribute, getattr(organization, attribute))
|
||||
db.session.add(service)
|
||||
|
||||
|
||||
@autocommit
|
||||
@version_class(Service)
|
||||
def dao_add_service_to_organization(service, organization_id):
|
||||
organization = Organization.query.filter_by(id=organization_id).one()
|
||||
|
||||
service.organization_id = organization_id
|
||||
service.organization_type = organization.organization_type
|
||||
|
||||
db.session.add(service)
|
||||
|
||||
|
||||
def dao_get_users_for_organization(organization_id):
|
||||
return (
|
||||
db.session.query(User)
|
||||
.join(User.organizations)
|
||||
.filter(Organization.id == organization_id, User.state == "active")
|
||||
.order_by(User.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_add_user_to_organization(organization_id, user_id):
|
||||
organization = dao_get_organization_by_id(organization_id)
|
||||
user = User.query.filter_by(id=user_id).one()
|
||||
user.organizations.append(organization)
|
||||
db.session.add(organization)
|
||||
return user
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_remove_user_from_organization(organization, user):
|
||||
organization.users.remove(user)
|
||||
@@ -1,15 +1,34 @@
|
||||
from app import db
|
||||
from app.dao import DAOClass
|
||||
from app.enums import PermissionType
|
||||
from app.models import Permission
|
||||
from app.models import (
|
||||
MANAGE_API_KEYS,
|
||||
MANAGE_SETTINGS,
|
||||
MANAGE_TEMPLATES,
|
||||
MANAGE_USERS,
|
||||
SEND_EMAILS,
|
||||
SEND_TEXTS,
|
||||
VIEW_ACTIVITY,
|
||||
Permission,
|
||||
)
|
||||
|
||||
# Default permissions for a service
|
||||
default_service_permissions = [
|
||||
MANAGE_USERS,
|
||||
MANAGE_TEMPLATES,
|
||||
MANAGE_SETTINGS,
|
||||
SEND_TEXTS,
|
||||
SEND_EMAILS,
|
||||
MANAGE_API_KEYS,
|
||||
VIEW_ACTIVITY]
|
||||
|
||||
|
||||
class PermissionDAO(DAOClass):
|
||||
|
||||
class Meta:
|
||||
model = Permission
|
||||
|
||||
def add_default_service_permissions_for_user(self, user, service):
|
||||
for name in PermissionType.defaults():
|
||||
for name in default_service_permissions:
|
||||
permission = Permission(permission=name, user=user, service=service)
|
||||
self.create_instance(permission, _commit=False)
|
||||
|
||||
@@ -21,9 +40,7 @@ class PermissionDAO(DAOClass):
|
||||
query = self.Meta.model.query.filter_by(user=user)
|
||||
query.delete()
|
||||
|
||||
def set_user_service_permission(
|
||||
self, user, service, permissions, _commit=False, replace=False
|
||||
):
|
||||
def set_user_service_permission(self, user, service, permissions, _commit=False, replace=False):
|
||||
try:
|
||||
if replace:
|
||||
query = self.Meta.model.query.filter_by(user=user, service=service)
|
||||
@@ -41,20 +58,12 @@ class PermissionDAO(DAOClass):
|
||||
db.session.commit()
|
||||
|
||||
def get_permissions_by_user_id(self, user_id):
|
||||
return (
|
||||
self.Meta.model.query.filter_by(user_id=user_id)
|
||||
.join(Permission.service)
|
||||
.filter_by(active=True)
|
||||
.all()
|
||||
)
|
||||
return self.Meta.model.query.filter_by(user_id=user_id)\
|
||||
.join(Permission.service).filter_by(active=True).all()
|
||||
|
||||
def get_permissions_by_user_id_and_service_id(self, user_id, service_id):
|
||||
return (
|
||||
self.Meta.model.query.filter_by(user_id=user_id)
|
||||
.join(Permission.service)
|
||||
.filter_by(active=True, id=service_id)
|
||||
.all()
|
||||
)
|
||||
return self.Meta.model.query.filter_by(user_id=user_id)\
|
||||
.join(Permission.service).filter_by(active=True, id=service_id).all()
|
||||
|
||||
|
||||
permission_dao = PermissionDAO()
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.timezones import convert_utc_to_local_timezone
|
||||
from sqlalchemy import asc, desc, func
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.enums import NotificationType
|
||||
from app.models import FactBilling, ProviderDetails, ProviderDetailsHistory, User
|
||||
from app.models import (
|
||||
SMS_TYPE,
|
||||
FactBilling,
|
||||
ProviderDetails,
|
||||
ProviderDetailsHistory,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def get_provider_details_by_id(provider_details_id):
|
||||
@@ -18,28 +24,29 @@ def get_provider_details_by_identifier(identifier):
|
||||
|
||||
|
||||
def get_alternative_sms_provider(identifier):
|
||||
if identifier == "sns":
|
||||
if identifier == 'sns':
|
||||
raise Exception("No alternative SMS providers currently available")
|
||||
raise ValueError("Unrecognised sms provider {}".format(identifier))
|
||||
raise ValueError('Unrecognised sms provider {}'.format(identifier))
|
||||
|
||||
|
||||
def dao_get_provider_versions(provider_id):
|
||||
return (
|
||||
ProviderDetailsHistory.query.filter_by(id=provider_id)
|
||||
.order_by(desc(ProviderDetailsHistory.version))
|
||||
.limit(100) # limit results instead of adding pagination
|
||||
.all()
|
||||
)
|
||||
return ProviderDetailsHistory.query.filter_by(
|
||||
id=provider_id
|
||||
).order_by(
|
||||
desc(ProviderDetailsHistory.version)
|
||||
).limit(
|
||||
100 # limit results instead of adding pagination
|
||||
).all()
|
||||
|
||||
|
||||
def _adjust_provider_priority(provider, new_priority):
|
||||
current_app.logger.info(
|
||||
f"Adjusting provider priority - {provider.identifier} going from {provider.priority} to {new_priority}"
|
||||
f'Adjusting provider priority - {provider.identifier} going from {provider.priority} to {new_priority}'
|
||||
)
|
||||
provider.priority = new_priority
|
||||
|
||||
# Automatic update so set as notify user
|
||||
provider.created_by_id = current_app.config["NOTIFY_USER_ID"]
|
||||
provider.created_by_id = current_app.config['NOTIFY_USER_ID']
|
||||
|
||||
# update without commit so that both rows can be changed without ending the transaction
|
||||
# and releasing the for_update lock
|
||||
@@ -55,41 +62,78 @@ def _get_sms_providers_for_update(time_threshold):
|
||||
release the transaction in that case
|
||||
"""
|
||||
# get current priority of both providers
|
||||
q = (
|
||||
ProviderDetails.query.filter(
|
||||
ProviderDetails.notification_type == NotificationType.SMS,
|
||||
ProviderDetails.active,
|
||||
)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
q = ProviderDetails.query.filter(
|
||||
ProviderDetails.notification_type == 'sms',
|
||||
ProviderDetails.active
|
||||
).with_for_update().all()
|
||||
|
||||
# if something updated recently, don't update again. If the updated_at is null, treat it as min time
|
||||
if any(
|
||||
(provider.updated_at or datetime.min) > datetime.utcnow() - time_threshold
|
||||
for provider in q
|
||||
):
|
||||
current_app.logger.info(
|
||||
f"Not adjusting providers, providers updated less than {time_threshold} ago."
|
||||
)
|
||||
if any((provider.updated_at or datetime.min) > datetime.utcnow() - time_threshold for provider in q):
|
||||
current_app.logger.info(f"Not adjusting providers, providers updated less than {time_threshold} ago.")
|
||||
return []
|
||||
|
||||
return q
|
||||
|
||||
|
||||
def get_provider_details_by_notification_type(
|
||||
notification_type, supports_international=False
|
||||
):
|
||||
@autocommit
|
||||
def dao_reduce_sms_provider_priority(identifier, *, time_threshold):
|
||||
"""
|
||||
Will reduce a chosen sms provider's priority, and increase the other provider's priority by 10 points each.
|
||||
If either provider has been updated in the last `time_threshold`, then it won't take any action.
|
||||
"""
|
||||
amount_to_reduce_by = 10
|
||||
providers_list = _get_sms_providers_for_update(time_threshold)
|
||||
|
||||
if len(providers_list) < 2:
|
||||
current_app.logger.info("Not adjusting providers, number of active providers is less than 2.")
|
||||
return
|
||||
|
||||
providers = {provider.identifier: provider for provider in providers_list}
|
||||
other_identifier = get_alternative_sms_provider(identifier)
|
||||
|
||||
reduced_provider = providers[identifier]
|
||||
increased_provider = providers[other_identifier]
|
||||
|
||||
# always keep values between 0 and 100
|
||||
reduced_provider_priority = max(0, reduced_provider.priority - amount_to_reduce_by)
|
||||
increased_provider_priority = min(100, increased_provider.priority + amount_to_reduce_by)
|
||||
|
||||
_adjust_provider_priority(reduced_provider, reduced_provider_priority)
|
||||
_adjust_provider_priority(increased_provider, increased_provider_priority)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_adjust_provider_priority_back_to_resting_points():
|
||||
"""
|
||||
Provided that neither SMS provider has been modified in the last hour, move both providers by 10 percentage points
|
||||
each towards their defined resting points (set in SMS_PROVIDER_RESTING_POINTS in config.py).
|
||||
"""
|
||||
amount_to_reduce_by = 10
|
||||
time_threshold = timedelta(hours=1)
|
||||
|
||||
providers = _get_sms_providers_for_update(time_threshold)
|
||||
|
||||
for provider in providers:
|
||||
target = current_app.config['SMS_PROVIDER_RESTING_POINTS'][provider.identifier]
|
||||
current = provider.priority
|
||||
|
||||
if current != target:
|
||||
if current > target:
|
||||
new_priority = max(target, provider.priority - amount_to_reduce_by)
|
||||
else:
|
||||
new_priority = min(target, provider.priority + amount_to_reduce_by)
|
||||
|
||||
_adjust_provider_priority(provider, new_priority)
|
||||
|
||||
|
||||
def get_provider_details_by_notification_type(notification_type, supports_international=False):
|
||||
|
||||
filters = [ProviderDetails.notification_type == notification_type]
|
||||
|
||||
if supports_international:
|
||||
filters.append(ProviderDetails.supports_international == supports_international)
|
||||
|
||||
return (
|
||||
ProviderDetails.query.filter(*filters)
|
||||
.order_by(asc(ProviderDetails.priority))
|
||||
.all()
|
||||
)
|
||||
return ProviderDetails.query.filter(*filters).order_by(asc(ProviderDetails.priority)).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -111,46 +155,37 @@ def _update_provider_details_without_commit(provider_details):
|
||||
def dao_get_provider_stats():
|
||||
# this query does not include the current day since the task to populate ft_billing runs overnight
|
||||
|
||||
current_datetime = datetime.utcnow()
|
||||
first_day_of_the_month = current_datetime.date().replace(day=1)
|
||||
current_local_datetime = convert_utc_to_local_timezone(datetime.utcnow())
|
||||
first_day_of_the_month = current_local_datetime.date().replace(day=1)
|
||||
|
||||
subquery = (
|
||||
db.session.query(
|
||||
FactBilling.provider,
|
||||
func.sum(FactBilling.billable_units * FactBilling.rate_multiplier).label(
|
||||
"current_month_billable_sms"
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
FactBilling.notification_type == NotificationType.SMS,
|
||||
FactBilling.local_date >= first_day_of_the_month,
|
||||
)
|
||||
.group_by(FactBilling.provider)
|
||||
.subquery()
|
||||
)
|
||||
subquery = db.session.query(
|
||||
FactBilling.provider,
|
||||
func.sum(FactBilling.billable_units * FactBilling.rate_multiplier).label('current_month_billable_sms')
|
||||
).filter(
|
||||
FactBilling.notification_type == SMS_TYPE,
|
||||
FactBilling.local_date >= first_day_of_the_month
|
||||
).group_by(
|
||||
FactBilling.provider
|
||||
).subquery()
|
||||
|
||||
result = (
|
||||
db.session.query(
|
||||
ProviderDetails.id,
|
||||
ProviderDetails.display_name,
|
||||
ProviderDetails.identifier,
|
||||
ProviderDetails.priority,
|
||||
ProviderDetails.notification_type,
|
||||
ProviderDetails.active,
|
||||
ProviderDetails.updated_at,
|
||||
ProviderDetails.supports_international,
|
||||
User.name.label("created_by_name"),
|
||||
func.coalesce(subquery.c.current_month_billable_sms, 0).label(
|
||||
"current_month_billable_sms"
|
||||
),
|
||||
)
|
||||
.outerjoin(subquery, ProviderDetails.identifier == subquery.c.provider)
|
||||
.outerjoin(User, ProviderDetails.created_by_id == User.id)
|
||||
.order_by(
|
||||
ProviderDetails.notification_type,
|
||||
ProviderDetails.priority,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
result = db.session.query(
|
||||
ProviderDetails.id,
|
||||
ProviderDetails.display_name,
|
||||
ProviderDetails.identifier,
|
||||
ProviderDetails.priority,
|
||||
ProviderDetails.notification_type,
|
||||
ProviderDetails.active,
|
||||
ProviderDetails.updated_at,
|
||||
ProviderDetails.supports_international,
|
||||
User.name.label('created_by_name'),
|
||||
func.coalesce(subquery.c.current_month_billable_sms, 0).label('current_month_billable_sms')
|
||||
).outerjoin(
|
||||
subquery, ProviderDetails.identifier == subquery.c.provider
|
||||
).outerjoin(
|
||||
User, ProviderDetails.created_by_id == User.id
|
||||
).order_by(
|
||||
ProviderDetails.notification_type,
|
||||
ProviderDetails.priority,
|
||||
).all()
|
||||
|
||||
return result
|
||||
|
||||
@@ -2,8 +2,11 @@ from datetime import datetime
|
||||
|
||||
from app import create_uuid, db
|
||||
from app.dao.dao_utils import autocommit, version_class
|
||||
from app.enums import CallbackType
|
||||
from app.models import ServiceCallbackApi
|
||||
from app.models import (
|
||||
COMPLAINT_CALLBACK_TYPE,
|
||||
DELIVERY_STATUS_CALLBACK_TYPE,
|
||||
ServiceCallbackApi,
|
||||
)
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -16,9 +19,7 @@ def save_service_callback_api(service_callback_api):
|
||||
|
||||
@autocommit
|
||||
@version_class(ServiceCallbackApi)
|
||||
def reset_service_callback_api(
|
||||
service_callback_api, updated_by_id, url=None, bearer_token=None
|
||||
):
|
||||
def reset_service_callback_api(service_callback_api, updated_by_id, url=None, bearer_token=None):
|
||||
if url:
|
||||
service_callback_api.url = url
|
||||
if bearer_token:
|
||||
@@ -30,22 +31,20 @@ def reset_service_callback_api(
|
||||
|
||||
|
||||
def get_service_callback_api(service_callback_api_id, service_id):
|
||||
return ServiceCallbackApi.query.filter_by(
|
||||
id=service_callback_api_id, service_id=service_id
|
||||
).first()
|
||||
return ServiceCallbackApi.query.filter_by(id=service_callback_api_id, service_id=service_id).first()
|
||||
|
||||
|
||||
def get_service_delivery_status_callback_api_for_service(service_id):
|
||||
return ServiceCallbackApi.query.filter_by(
|
||||
service_id=service_id,
|
||||
callback_type=CallbackType.DELIVERY_STATUS,
|
||||
callback_type=DELIVERY_STATUS_CALLBACK_TYPE
|
||||
).first()
|
||||
|
||||
|
||||
def get_service_complaint_callback_api_for_service(service_id):
|
||||
return ServiceCallbackApi.query.filter_by(
|
||||
service_id=service_id,
|
||||
callback_type=CallbackType.COMPLAINT,
|
||||
callback_type=COMPLAINT_CALLBACK_TYPE
|
||||
).first()
|
||||
|
||||
|
||||
|
||||
33
app/dao/service_contact_list_dao.py
Normal file
33
app/dao/service_contact_list_dao.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from app import db
|
||||
from app.models import ServiceContactList
|
||||
|
||||
|
||||
def dao_get_contact_list_by_id(service_id, contact_list_id):
|
||||
contact_list = ServiceContactList.query.filter_by(
|
||||
service_id=service_id,
|
||||
id=contact_list_id,
|
||||
archived=False,
|
||||
).one()
|
||||
|
||||
return contact_list
|
||||
|
||||
|
||||
def dao_get_contact_lists(service_id):
|
||||
contact_lists = ServiceContactList.query.filter_by(
|
||||
service_id=service_id,
|
||||
archived=False,
|
||||
).order_by(
|
||||
ServiceContactList.created_at.desc()
|
||||
)
|
||||
return contact_lists.all()
|
||||
|
||||
|
||||
def save_service_contact_list(service_contact_list):
|
||||
db.session.add(service_contact_list)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def dao_archive_contact_list(service_contact_list):
|
||||
service_contact_list.archived = True
|
||||
db.session.add(service_contact_list)
|
||||
db.session.commit()
|
||||
@@ -6,57 +6,51 @@ from app.models import ServiceDataRetention
|
||||
|
||||
|
||||
def fetch_service_data_retention_by_id(service_id, data_retention_id):
|
||||
data_retention = ServiceDataRetention.query.filter_by(
|
||||
service_id=service_id, id=data_retention_id
|
||||
).first()
|
||||
data_retention = ServiceDataRetention.query.filter_by(service_id=service_id, id=data_retention_id).first()
|
||||
return data_retention
|
||||
|
||||
|
||||
def fetch_service_data_retention(service_id):
|
||||
data_retention_list = (
|
||||
ServiceDataRetention.query.filter_by(service_id=service_id)
|
||||
.order_by(
|
||||
# in the order that models.notification_types are created (email, sms, letter)
|
||||
ServiceDataRetention.notification_type
|
||||
)
|
||||
.all()
|
||||
)
|
||||
data_retention_list = ServiceDataRetention.query.filter_by(
|
||||
service_id=service_id
|
||||
).order_by(
|
||||
# in the order that models.notification_types are created (email, sms, letter)
|
||||
ServiceDataRetention.notification_type
|
||||
).all()
|
||||
return data_retention_list
|
||||
|
||||
|
||||
def fetch_service_data_retention_by_notification_type(service_id, notification_type):
|
||||
data_retention_list = ServiceDataRetention.query.filter_by(
|
||||
service_id=service_id, notification_type=notification_type
|
||||
service_id=service_id,
|
||||
notification_type=notification_type
|
||||
).first()
|
||||
return data_retention_list
|
||||
|
||||
|
||||
@autocommit
|
||||
def insert_service_data_retention(service_id, notification_type, days_of_retention):
|
||||
new_data_retention = ServiceDataRetention(
|
||||
service_id=service_id,
|
||||
notification_type=notification_type,
|
||||
days_of_retention=days_of_retention,
|
||||
)
|
||||
new_data_retention = ServiceDataRetention(service_id=service_id,
|
||||
notification_type=notification_type,
|
||||
days_of_retention=days_of_retention)
|
||||
|
||||
db.session.add(new_data_retention)
|
||||
return new_data_retention
|
||||
|
||||
|
||||
@autocommit
|
||||
def update_service_data_retention(
|
||||
service_data_retention_id, service_id, days_of_retention
|
||||
):
|
||||
def update_service_data_retention(service_data_retention_id, service_id, days_of_retention):
|
||||
updated_count = ServiceDataRetention.query.filter(
|
||||
ServiceDataRetention.id == service_data_retention_id,
|
||||
ServiceDataRetention.service_id == service_id,
|
||||
).update({"days_of_retention": days_of_retention, "updated_at": datetime.utcnow()})
|
||||
ServiceDataRetention.service_id == service_id
|
||||
).update(
|
||||
{
|
||||
"days_of_retention": days_of_retention,
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
)
|
||||
return updated_count
|
||||
|
||||
|
||||
def fetch_service_data_retention_for_all_services_by_notification_type(
|
||||
notification_type,
|
||||
):
|
||||
return ServiceDataRetention.query.filter(
|
||||
ServiceDataRetention.notification_type == notification_type
|
||||
).all()
|
||||
def fetch_service_data_retention_for_all_services_by_notification_type(notification_type):
|
||||
return ServiceDataRetention.query.filter(ServiceDataRetention.notification_type == notification_type).all()
|
||||
|
||||
@@ -8,31 +8,23 @@ from app.models import ServiceEmailReplyTo
|
||||
|
||||
|
||||
def dao_get_reply_to_by_service_id(service_id):
|
||||
reply_to = (
|
||||
db.session.query(ServiceEmailReplyTo)
|
||||
.filter(
|
||||
ServiceEmailReplyTo.service_id == service_id,
|
||||
ServiceEmailReplyTo.archived == False, # noqa
|
||||
)
|
||||
.order_by(
|
||||
desc(ServiceEmailReplyTo.is_default), desc(ServiceEmailReplyTo.created_at)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
reply_to = db.session.query(
|
||||
ServiceEmailReplyTo
|
||||
).filter(
|
||||
ServiceEmailReplyTo.service_id == service_id,
|
||||
ServiceEmailReplyTo.archived == False # noqa
|
||||
).order_by(desc(ServiceEmailReplyTo.is_default), desc(ServiceEmailReplyTo.created_at)).all()
|
||||
return reply_to
|
||||
|
||||
|
||||
def dao_get_reply_to_by_id(service_id, reply_to_id):
|
||||
reply_to = (
|
||||
db.session.query(ServiceEmailReplyTo)
|
||||
.filter(
|
||||
ServiceEmailReplyTo.service_id == service_id,
|
||||
ServiceEmailReplyTo.id == reply_to_id,
|
||||
ServiceEmailReplyTo.archived == False, # noqa
|
||||
)
|
||||
.order_by(ServiceEmailReplyTo.created_at)
|
||||
.one()
|
||||
)
|
||||
reply_to = db.session.query(
|
||||
ServiceEmailReplyTo
|
||||
).filter(
|
||||
ServiceEmailReplyTo.service_id == service_id,
|
||||
ServiceEmailReplyTo.id == reply_to_id,
|
||||
ServiceEmailReplyTo.archived == False # noqa
|
||||
).order_by(ServiceEmailReplyTo.created_at).one()
|
||||
return reply_to
|
||||
|
||||
|
||||
@@ -44,9 +36,7 @@ def add_reply_to_email_address_for_service(service_id, email_address, is_default
|
||||
else:
|
||||
_raise_when_no_default(old_default)
|
||||
|
||||
new_reply_to = ServiceEmailReplyTo(
|
||||
service_id=service_id, email_address=email_address, is_default=is_default
|
||||
)
|
||||
new_reply_to = ServiceEmailReplyTo(service_id=service_id, email_address=email_address, is_default=is_default)
|
||||
db.session.add(new_reply_to)
|
||||
return new_reply_to
|
||||
|
||||
@@ -58,9 +48,7 @@ def update_reply_to_email_address(service_id, reply_to_id, email_address, is_def
|
||||
_reset_old_default_to_false(old_default)
|
||||
else:
|
||||
if old_default.id == reply_to_id:
|
||||
raise InvalidRequest(
|
||||
"You must have at least one reply to email address as the default.", 400
|
||||
)
|
||||
raise InvalidRequest("You must have at least one reply to email address as the default.", 400)
|
||||
|
||||
reply_to_update = ServiceEmailReplyTo.query.get(reply_to_id)
|
||||
reply_to_update.email_address = email_address
|
||||
@@ -72,13 +60,12 @@ def update_reply_to_email_address(service_id, reply_to_id, email_address, is_def
|
||||
@autocommit
|
||||
def archive_reply_to_email_address(service_id, reply_to_id):
|
||||
reply_to_archive = ServiceEmailReplyTo.query.filter_by(
|
||||
id=reply_to_id, service_id=service_id
|
||||
id=reply_to_id,
|
||||
service_id=service_id
|
||||
).one()
|
||||
|
||||
if reply_to_archive.is_default:
|
||||
raise ArchiveValidationError(
|
||||
"You cannot delete a default email reply to address"
|
||||
)
|
||||
raise ArchiveValidationError("You cannot delete a default email reply to address")
|
||||
|
||||
reply_to_archive.archived = True
|
||||
|
||||
@@ -95,9 +82,7 @@ def _get_existing_default(service_id):
|
||||
else:
|
||||
raise Exception(
|
||||
"There should only be one default reply to email for each service. Service {} has {}".format(
|
||||
service_id, len(old_default)
|
||||
)
|
||||
)
|
||||
service_id, len(old_default)))
|
||||
return None
|
||||
|
||||
|
||||
@@ -110,6 +95,4 @@ def _reset_old_default_to_false(old_default):
|
||||
def _raise_when_no_default(old_default):
|
||||
# check that the update is not updating the only default to false
|
||||
if not old_default:
|
||||
raise InvalidRequest(
|
||||
"You must have at least one reply to email address as the default.", 400
|
||||
)
|
||||
raise InvalidRequest("You must have at least one reply to email address as the default.", 400)
|
||||
|
||||
@@ -4,8 +4,7 @@ from app.models import ServiceGuestList
|
||||
|
||||
def dao_fetch_service_guest_list(service_id):
|
||||
return ServiceGuestList.query.filter(
|
||||
ServiceGuestList.service_id == service_id
|
||||
).all()
|
||||
ServiceGuestList.service_id == service_id).all()
|
||||
|
||||
|
||||
def dao_add_and_commit_guest_list_contacts(objs):
|
||||
@@ -15,5 +14,4 @@ def dao_add_and_commit_guest_list_contacts(objs):
|
||||
|
||||
def dao_remove_service_guest_list(service_id):
|
||||
return ServiceGuestList.query.filter(
|
||||
ServiceGuestList.service_id == service_id
|
||||
).delete()
|
||||
ServiceGuestList.service_id == service_id).delete()
|
||||
|
||||
@@ -15,9 +15,7 @@ def save_service_inbound_api(service_inbound_api):
|
||||
|
||||
@autocommit
|
||||
@version_class(ServiceInboundApi)
|
||||
def reset_service_inbound_api(
|
||||
service_inbound_api, updated_by_id, url=None, bearer_token=None
|
||||
):
|
||||
def reset_service_inbound_api(service_inbound_api, updated_by_id, url=None, bearer_token=None):
|
||||
if url:
|
||||
service_inbound_api.url = url
|
||||
if bearer_token:
|
||||
@@ -29,9 +27,8 @@ def reset_service_inbound_api(
|
||||
|
||||
|
||||
def get_service_inbound_api(service_inbound_api_id, service_id):
|
||||
return ServiceInboundApi.query.filter_by(
|
||||
id=service_inbound_api_id, service_id=service_id
|
||||
).first()
|
||||
return ServiceInboundApi.query.filter_by(id=service_inbound_api_id,
|
||||
service_id=service_id).first()
|
||||
|
||||
|
||||
def get_service_inbound_api_for_service(service_id):
|
||||
|
||||
@@ -5,8 +5,7 @@ from app.models import ServicePermission
|
||||
|
||||
def dao_fetch_service_permissions(service_id):
|
||||
return ServicePermission.query.filter(
|
||||
ServicePermission.service_id == service_id
|
||||
).all()
|
||||
ServicePermission.service_id == service_id).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -18,7 +17,6 @@ def dao_add_service_permission(service_id, permission):
|
||||
def dao_remove_service_permission(service_id, permission):
|
||||
deleted = ServicePermission.query.filter(
|
||||
ServicePermission.service_id == service_id,
|
||||
ServicePermission.permission == permission,
|
||||
).delete()
|
||||
ServicePermission.permission == permission).delete()
|
||||
db.session.commit()
|
||||
return deleted
|
||||
|
||||
@@ -10,30 +10,30 @@ def insert_service_sms_sender(service, sms_sender):
|
||||
"""
|
||||
This method is called from create_service which is wrapped in a transaction.
|
||||
"""
|
||||
new_sms_sender = ServiceSmsSender(
|
||||
sms_sender=sms_sender, service=service, is_default=True
|
||||
)
|
||||
new_sms_sender = ServiceSmsSender(sms_sender=sms_sender,
|
||||
service=service,
|
||||
is_default=True
|
||||
)
|
||||
db.session.add(new_sms_sender)
|
||||
|
||||
|
||||
def dao_get_service_sms_senders_by_id(service_id, service_sms_sender_id):
|
||||
return ServiceSmsSender.query.filter_by(
|
||||
id=service_sms_sender_id, service_id=service_id, archived=False
|
||||
id=service_sms_sender_id,
|
||||
service_id=service_id,
|
||||
archived=False
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_sms_senders_by_service_id(service_id):
|
||||
return (
|
||||
ServiceSmsSender.query.filter_by(service_id=service_id, archived=False)
|
||||
.order_by(desc(ServiceSmsSender.is_default))
|
||||
.all()
|
||||
)
|
||||
return ServiceSmsSender.query.filter_by(
|
||||
service_id=service_id,
|
||||
archived=False
|
||||
).order_by(desc(ServiceSmsSender.is_default)).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_add_sms_sender_for_service(
|
||||
service_id, sms_sender, is_default, inbound_number_id=None
|
||||
):
|
||||
def dao_add_sms_sender_for_service(service_id, sms_sender, is_default, inbound_number_id=None):
|
||||
old_default = _get_existing_default(service_id=service_id)
|
||||
if is_default:
|
||||
_reset_old_default_to_false(old_default)
|
||||
@@ -44,7 +44,7 @@ def dao_add_sms_sender_for_service(
|
||||
service_id=service_id,
|
||||
sms_sender=sms_sender,
|
||||
is_default=is_default,
|
||||
inbound_number_id=inbound_number_id,
|
||||
inbound_number_id=inbound_number_id
|
||||
)
|
||||
|
||||
db.session.add(new_sms_sender)
|
||||
@@ -52,9 +52,7 @@ def dao_add_sms_sender_for_service(
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_update_service_sms_sender(
|
||||
service_id, service_sms_sender_id, is_default, sms_sender=None
|
||||
):
|
||||
def dao_update_service_sms_sender(service_id, service_sms_sender_id, is_default, sms_sender=None):
|
||||
old_default = _get_existing_default(service_id)
|
||||
if is_default:
|
||||
_reset_old_default_to_false(old_default)
|
||||
@@ -71,9 +69,7 @@ def dao_update_service_sms_sender(
|
||||
|
||||
|
||||
@autocommit
|
||||
def update_existing_sms_sender_with_inbound_number(
|
||||
service_sms_sender, sms_sender, inbound_number_id
|
||||
):
|
||||
def update_existing_sms_sender_with_inbound_number(service_sms_sender, sms_sender, inbound_number_id):
|
||||
service_sms_sender.sms_sender = sms_sender
|
||||
service_sms_sender.inbound_number_id = inbound_number_id
|
||||
db.session.add(service_sms_sender)
|
||||
@@ -83,7 +79,8 @@ def update_existing_sms_sender_with_inbound_number(
|
||||
@autocommit
|
||||
def archive_sms_sender(service_id, sms_sender_id):
|
||||
sms_sender_to_archive = ServiceSmsSender.query.filter_by(
|
||||
id=sms_sender_id, service_id=service_id
|
||||
id=sms_sender_id,
|
||||
service_id=service_id
|
||||
).one()
|
||||
|
||||
if sms_sender_to_archive.inbound_number_id:
|
||||
@@ -106,7 +103,8 @@ def _get_existing_default(service_id):
|
||||
else:
|
||||
raise Exception(
|
||||
"There should only be one default sms sender for each service. Service {} has {}".format(
|
||||
service_id, len(old_default)
|
||||
service_id,
|
||||
len(old_default)
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import ServiceUser, User
|
||||
@@ -8,10 +9,13 @@ def dao_get_service_user(user_id, service_id):
|
||||
|
||||
|
||||
def dao_get_active_service_users(service_id):
|
||||
query = (
|
||||
db.session.query(ServiceUser)
|
||||
.join(User, User.id == ServiceUser.user_id)
|
||||
.filter(User.state == "active", ServiceUser.service_id == service_id)
|
||||
query = db.session.query(
|
||||
ServiceUser
|
||||
).join(
|
||||
User, User.id == ServiceUser.user_id
|
||||
).filter(
|
||||
User.state == 'active',
|
||||
ServiceUser.service_id == service_id
|
||||
)
|
||||
|
||||
return query.all()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import Float, cast
|
||||
@@ -8,18 +8,17 @@ from sqlalchemy.sql.expression import and_, asc, case, func
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import VersionOptions, autocommit, version_class
|
||||
from app.dao.date_util import get_current_calendar_year
|
||||
from app.dao.organization_dao import dao_get_organization_by_email_address
|
||||
from app.dao.date_util import get_current_financial_year
|
||||
from app.dao.organisation_dao import dao_get_organisation_by_email_address
|
||||
from app.dao.service_sms_sender_dao import insert_service_sms_sender
|
||||
from app.dao.service_user_dao import dao_get_service_user
|
||||
from app.dao.template_folder_dao import dao_get_valid_template_folders_by_id
|
||||
from app.enums import (
|
||||
KeyType,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
ServicePermissionType,
|
||||
)
|
||||
from app.models import (
|
||||
EMAIL_TYPE,
|
||||
INTERNATIONAL_SMS_TYPE,
|
||||
KEY_TYPE_TEST,
|
||||
NOTIFICATION_PERMANENT_FAILURE,
|
||||
SMS_TYPE,
|
||||
AnnualBilling,
|
||||
ApiKey,
|
||||
FactBilling,
|
||||
@@ -28,9 +27,10 @@ from app.models import (
|
||||
Job,
|
||||
Notification,
|
||||
NotificationHistory,
|
||||
Organization,
|
||||
Organisation,
|
||||
Permission,
|
||||
Service,
|
||||
ServiceContactList,
|
||||
ServiceEmailReplyTo,
|
||||
ServicePermission,
|
||||
ServiceSmsSender,
|
||||
@@ -43,12 +43,22 @@ from app.models import (
|
||||
from app.utils import (
|
||||
escape_special_characters,
|
||||
get_archived_db_column_value,
|
||||
get_midnight_in_utc,
|
||||
get_local_midnight_in_utc,
|
||||
)
|
||||
|
||||
DEFAULT_SERVICE_PERMISSIONS = [
|
||||
SMS_TYPE,
|
||||
EMAIL_TYPE,
|
||||
INTERNATIONAL_SMS_TYPE,
|
||||
]
|
||||
|
||||
|
||||
def dao_fetch_all_services(only_active=False):
|
||||
query = Service.query.order_by(asc(Service.created_at)).options(joinedload("users"))
|
||||
query = Service.query.order_by(
|
||||
asc(Service.created_at)
|
||||
).options(
|
||||
joinedload('users')
|
||||
)
|
||||
|
||||
if only_active:
|
||||
query = query.filter(Service.active)
|
||||
@@ -70,100 +80,79 @@ def dao_count_live_services():
|
||||
|
||||
|
||||
def dao_fetch_live_services_data():
|
||||
year_start_date, year_end_date = get_current_calendar_year()
|
||||
year_start_date, year_end_date = get_current_financial_year()
|
||||
|
||||
most_recent_annual_billing = (
|
||||
db.session.query(
|
||||
AnnualBilling.service_id,
|
||||
func.max(AnnualBilling.financial_year_start).label("year"),
|
||||
)
|
||||
.group_by(AnnualBilling.service_id)
|
||||
.subquery()
|
||||
)
|
||||
most_recent_annual_billing = db.session.query(
|
||||
AnnualBilling.service_id,
|
||||
func.max(AnnualBilling.financial_year_start).label('year')
|
||||
).group_by(
|
||||
AnnualBilling.service_id
|
||||
).subquery()
|
||||
|
||||
this_year_ft_billing = FactBilling.query.filter(
|
||||
FactBilling.local_date >= year_start_date,
|
||||
FactBilling.local_date <= year_end_date,
|
||||
).subquery()
|
||||
|
||||
data = (
|
||||
db.session.query(
|
||||
Service.id.label("service_id"),
|
||||
Service.name.label("service_name"),
|
||||
Organization.name.label("organization_name"),
|
||||
Organization.organization_type.label("organization_type"),
|
||||
Service.consent_to_research.label("consent_to_research"),
|
||||
User.name.label("contact_name"),
|
||||
User.email_address.label("contact_email"),
|
||||
User.mobile_number.label("contact_mobile"),
|
||||
Service.go_live_at.label("live_date"),
|
||||
Service.volume_sms.label("sms_volume_intent"),
|
||||
Service.volume_email.label("email_volume_intent"),
|
||||
case(
|
||||
[
|
||||
(
|
||||
this_year_ft_billing.c.notification_type
|
||||
== NotificationType.EMAIL,
|
||||
func.sum(this_year_ft_billing.c.notifications_sent),
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
).label("email_totals"),
|
||||
case(
|
||||
[
|
||||
(
|
||||
this_year_ft_billing.c.notification_type
|
||||
== NotificationType.SMS,
|
||||
func.sum(this_year_ft_billing.c.notifications_sent),
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
).label("sms_totals"),
|
||||
AnnualBilling.free_sms_fragment_limit,
|
||||
data = db.session.query(
|
||||
Service.id.label('service_id'),
|
||||
Service.name.label("service_name"),
|
||||
Organisation.name.label("organisation_name"),
|
||||
Organisation.organisation_type.label('organisation_type'),
|
||||
Service.consent_to_research.label('consent_to_research'),
|
||||
User.name.label('contact_name'),
|
||||
User.email_address.label('contact_email'),
|
||||
User.mobile_number.label('contact_mobile'),
|
||||
Service.go_live_at.label("live_date"),
|
||||
Service.volume_sms.label('sms_volume_intent'),
|
||||
Service.volume_email.label('email_volume_intent'),
|
||||
case([
|
||||
(this_year_ft_billing.c.notification_type == 'email', func.sum(this_year_ft_billing.c.notifications_sent))
|
||||
], else_=0).label("email_totals"),
|
||||
case([
|
||||
(this_year_ft_billing.c.notification_type == 'sms', func.sum(this_year_ft_billing.c.notifications_sent))
|
||||
], else_=0).label("sms_totals"),
|
||||
AnnualBilling.free_sms_fragment_limit,
|
||||
).join(
|
||||
Service.annual_billing
|
||||
).join(
|
||||
most_recent_annual_billing,
|
||||
and_(
|
||||
Service.id == most_recent_annual_billing.c.service_id,
|
||||
AnnualBilling.financial_year_start == most_recent_annual_billing.c.year
|
||||
)
|
||||
.join(Service.annual_billing)
|
||||
.join(
|
||||
most_recent_annual_billing,
|
||||
and_(
|
||||
Service.id == most_recent_annual_billing.c.service_id,
|
||||
AnnualBilling.financial_year_start == most_recent_annual_billing.c.year,
|
||||
),
|
||||
)
|
||||
.outerjoin(Service.organization)
|
||||
.outerjoin(
|
||||
this_year_ft_billing, Service.id == this_year_ft_billing.c.service_id
|
||||
)
|
||||
.outerjoin(User, Service.go_live_user_id == User.id)
|
||||
.filter(
|
||||
Service.count_as_live.is_(True),
|
||||
Service.active.is_(True),
|
||||
Service.restricted.is_(False),
|
||||
)
|
||||
.group_by(
|
||||
Service.id,
|
||||
Organization.name,
|
||||
Organization.organization_type,
|
||||
Service.name,
|
||||
Service.consent_to_research,
|
||||
Service.count_as_live,
|
||||
Service.go_live_user_id,
|
||||
User.name,
|
||||
User.email_address,
|
||||
User.mobile_number,
|
||||
Service.go_live_at,
|
||||
Service.volume_sms,
|
||||
Service.volume_email,
|
||||
this_year_ft_billing.c.notification_type,
|
||||
AnnualBilling.free_sms_fragment_limit,
|
||||
)
|
||||
.order_by(asc(Service.go_live_at))
|
||||
.all()
|
||||
)
|
||||
).outerjoin(
|
||||
Service.organisation
|
||||
).outerjoin(
|
||||
this_year_ft_billing, Service.id == this_year_ft_billing.c.service_id
|
||||
).outerjoin(
|
||||
User, Service.go_live_user_id == User.id
|
||||
).filter(
|
||||
Service.count_as_live.is_(True),
|
||||
Service.active.is_(True),
|
||||
Service.restricted.is_(False),
|
||||
).group_by(
|
||||
Service.id,
|
||||
Organisation.name,
|
||||
Organisation.organisation_type,
|
||||
Service.name,
|
||||
Service.consent_to_research,
|
||||
Service.count_as_live,
|
||||
Service.go_live_user_id,
|
||||
User.name,
|
||||
User.email_address,
|
||||
User.mobile_number,
|
||||
Service.go_live_at,
|
||||
Service.volume_sms,
|
||||
Service.volume_email,
|
||||
this_year_ft_billing.c.notification_type,
|
||||
AnnualBilling.free_sms_fragment_limit,
|
||||
).order_by(
|
||||
asc(Service.go_live_at)
|
||||
).all()
|
||||
results = []
|
||||
for row in data:
|
||||
existing_service = next(
|
||||
(x for x in results if x["service_id"] == row.service_id), None
|
||||
)
|
||||
existing_service = next((x for x in results if x['service_id'] == row.service_id), None)
|
||||
|
||||
if existing_service is not None:
|
||||
existing_service["email_totals"] += row.email_totals
|
||||
@@ -174,7 +163,11 @@ def dao_fetch_live_services_data():
|
||||
|
||||
|
||||
def dao_fetch_service_by_id(service_id, only_active=False):
|
||||
query = Service.query.filter_by(id=service_id).options(joinedload("users"))
|
||||
query = Service.query.filter_by(
|
||||
id=service_id
|
||||
).options(
|
||||
joinedload('users')
|
||||
)
|
||||
|
||||
if only_active:
|
||||
query = query.filter(Service.active)
|
||||
@@ -184,17 +177,24 @@ def dao_fetch_service_by_id(service_id, only_active=False):
|
||||
|
||||
def dao_fetch_service_by_inbound_number(number):
|
||||
inbound_number = InboundNumber.query.filter(
|
||||
InboundNumber.number == number, InboundNumber.active
|
||||
InboundNumber.number == number,
|
||||
InboundNumber.active
|
||||
).first()
|
||||
|
||||
if not inbound_number:
|
||||
return None
|
||||
|
||||
return Service.query.filter(Service.id == inbound_number.service_id).first()
|
||||
return Service.query.filter(
|
||||
Service.id == inbound_number.service_id
|
||||
).first()
|
||||
|
||||
|
||||
def dao_fetch_service_by_id_with_api_keys(service_id, only_active=False):
|
||||
query = Service.query.filter_by(id=service_id).options(joinedload("api_keys"))
|
||||
query = Service.query.filter_by(
|
||||
id=service_id
|
||||
).options(
|
||||
joinedload('api_keys')
|
||||
)
|
||||
|
||||
if only_active:
|
||||
query = query.filter(Service.active)
|
||||
@@ -203,10 +203,12 @@ def dao_fetch_service_by_id_with_api_keys(service_id, only_active=False):
|
||||
|
||||
|
||||
def dao_fetch_all_services_by_user(user_id, only_active=False):
|
||||
query = (
|
||||
Service.query.filter(Service.users.any(id=user_id))
|
||||
.order_by(asc(Service.created_at))
|
||||
.options(joinedload("users"))
|
||||
query = Service.query.filter(
|
||||
Service.users.any(id=user_id)
|
||||
).order_by(
|
||||
asc(Service.created_at)
|
||||
).options(
|
||||
joinedload('users')
|
||||
)
|
||||
|
||||
if only_active:
|
||||
@@ -216,7 +218,9 @@ def dao_fetch_all_services_by_user(user_id, only_active=False):
|
||||
|
||||
|
||||
def dao_fetch_all_services_created_by_user(user_id):
|
||||
query = Service.query.filter_by(created_by_id=user_id).order_by(
|
||||
query = Service.query.filter_by(
|
||||
created_by_id=user_id
|
||||
).order_by(
|
||||
asc(Service.created_at)
|
||||
)
|
||||
|
||||
@@ -232,15 +236,11 @@ def dao_fetch_all_services_created_by_user(user_id):
|
||||
def dao_archive_service(service_id):
|
||||
# have to eager load templates and api keys so that we don't flush when we loop through them
|
||||
# to ensure that db.session still contains the models when it comes to creating history objects
|
||||
service = (
|
||||
Service.query.options(
|
||||
joinedload("templates"),
|
||||
joinedload("templates.template_redacted"),
|
||||
joinedload("api_keys"),
|
||||
)
|
||||
.filter(Service.id == service_id)
|
||||
.one()
|
||||
)
|
||||
service = Service.query.options(
|
||||
joinedload('templates'),
|
||||
joinedload('templates.template_redacted'),
|
||||
joinedload('api_keys'),
|
||||
).filter(Service.id == service_id).one()
|
||||
|
||||
service.active = False
|
||||
service.name = get_archived_db_column_value(service.name)
|
||||
@@ -256,11 +256,12 @@ def dao_archive_service(service_id):
|
||||
|
||||
|
||||
def dao_fetch_service_by_id_and_user(service_id, user_id):
|
||||
return (
|
||||
Service.query.filter(Service.users.any(id=user_id), Service.id == service_id)
|
||||
.options(joinedload("users"))
|
||||
.one()
|
||||
)
|
||||
return Service.query.filter(
|
||||
Service.users.any(id=user_id),
|
||||
Service.id == service_id
|
||||
).options(
|
||||
joinedload('users')
|
||||
).one()
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -271,39 +272,38 @@ def dao_create_service(
|
||||
service_id=None,
|
||||
service_permissions=None,
|
||||
):
|
||||
|
||||
if not user:
|
||||
raise ValueError("Can't create a service without a user")
|
||||
|
||||
if service_permissions is None:
|
||||
service_permissions = ServicePermissionType.defaults()
|
||||
service_permissions = DEFAULT_SERVICE_PERMISSIONS
|
||||
|
||||
organization = dao_get_organization_by_email_address(user.email_address)
|
||||
organisation = dao_get_organisation_by_email_address(user.email_address)
|
||||
|
||||
from app.dao.permissions_dao import permission_dao
|
||||
|
||||
service.users.append(user)
|
||||
permission_dao.add_default_service_permissions_for_user(user, service)
|
||||
service.id = (
|
||||
service_id or uuid.uuid4()
|
||||
) # must be set now so version history model can use same id
|
||||
service.id = service_id or uuid.uuid4() # must be set now so version history model can use same id
|
||||
service.active = True
|
||||
service.research_mode = False
|
||||
|
||||
for permission in service_permissions:
|
||||
service_permission = ServicePermission(
|
||||
service_id=service.id, permission=permission
|
||||
)
|
||||
service_permission = ServicePermission(service_id=service.id, permission=permission)
|
||||
service.permissions.append(service_permission)
|
||||
|
||||
# do we just add the default - or will we get a value from FE?
|
||||
insert_service_sms_sender(service, current_app.config["FROM_NUMBER"])
|
||||
insert_service_sms_sender(service, current_app.config['FROM_NUMBER'])
|
||||
|
||||
if organization:
|
||||
service.organization_id = organization.id
|
||||
service.organization_type = organization.organization_type
|
||||
if organisation:
|
||||
service.organisation_id = organisation.id
|
||||
service.organisation_type = organisation.organisation_type
|
||||
|
||||
if organization.email_branding:
|
||||
service.email_branding = organization.email_branding
|
||||
if organisation.email_branding:
|
||||
service.email_branding = organisation.email_branding
|
||||
|
||||
if organisation:
|
||||
service.crown = organisation.crown
|
||||
service.count_as_live = not user.platform_admin
|
||||
|
||||
db.session.add(service)
|
||||
@@ -321,17 +321,12 @@ def dao_add_user_to_service(service, user, permissions=None, folder_permissions=
|
||||
|
||||
try:
|
||||
from app.dao.permissions_dao import permission_dao
|
||||
|
||||
service.users.append(user)
|
||||
permission_dao.set_user_service_permission(
|
||||
user, service, permissions, _commit=False
|
||||
)
|
||||
permission_dao.set_user_service_permission(user, service, permissions, _commit=False)
|
||||
db.session.add(service)
|
||||
|
||||
service_user = dao_get_service_user(user.id, service.id)
|
||||
valid_template_folders = dao_get_valid_template_folders_by_id(
|
||||
folder_permissions
|
||||
)
|
||||
valid_template_folders = dao_get_valid_template_folders_by_id(folder_permissions)
|
||||
service_user.folders = valid_template_folders
|
||||
db.session.add(service_user)
|
||||
|
||||
@@ -345,7 +340,6 @@ def dao_add_user_to_service(service, user, permissions=None, folder_permissions=
|
||||
def dao_remove_user_from_service(service, user):
|
||||
try:
|
||||
from app.dao.permissions_dao import permission_dao
|
||||
|
||||
permission_dao.remove_user_service_permissions(user, service)
|
||||
|
||||
service_user = dao_get_service_user(user.id, service.id)
|
||||
@@ -358,17 +352,17 @@ def dao_remove_user_from_service(service, user):
|
||||
|
||||
|
||||
def delete_service_and_all_associated_db_objects(service):
|
||||
|
||||
def _delete_commit(query):
|
||||
query.delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
subq = db.session.query(Template.id).filter_by(service=service).subquery()
|
||||
_delete_commit(
|
||||
TemplateRedacted.query.filter(TemplateRedacted.template_id.in_(subq))
|
||||
)
|
||||
_delete_commit(TemplateRedacted.query.filter(TemplateRedacted.template_id.in_(subq)))
|
||||
|
||||
_delete_commit(ServiceSmsSender.query.filter_by(service=service))
|
||||
_delete_commit(ServiceEmailReplyTo.query.filter_by(service=service))
|
||||
_delete_commit(ServiceContactList.query.filter_by(service=service))
|
||||
_delete_commit(InvitedUser.query.filter_by(service=service))
|
||||
_delete_commit(Permission.query.filter_by(service=service))
|
||||
_delete_commit(NotificationHistory.query.filter_by(service=service))
|
||||
@@ -381,14 +375,12 @@ def delete_service_and_all_associated_db_objects(service):
|
||||
_delete_commit(ApiKey.get_history_model().query.filter_by(service_id=service.id))
|
||||
_delete_commit(AnnualBilling.query.filter_by(service_id=service.id))
|
||||
|
||||
verify_codes = VerifyCode.query.join(User).filter(
|
||||
User.id.in_([x.id for x in service.users])
|
||||
)
|
||||
verify_codes = VerifyCode.query.join(User).filter(User.id.in_([x.id for x in service.users]))
|
||||
list(map(db.session.delete, verify_codes))
|
||||
db.session.commit()
|
||||
users = [x for x in service.users]
|
||||
for user in users:
|
||||
user.organizations = []
|
||||
user.organisations = []
|
||||
service.users.remove(user)
|
||||
_delete_commit(Service.get_history_model().query.filter_by(id=service.id))
|
||||
db.session.delete(service)
|
||||
@@ -399,68 +391,61 @@ def delete_service_and_all_associated_db_objects(service):
|
||||
|
||||
|
||||
def dao_fetch_todays_stats_for_service(service_id):
|
||||
today = datetime.utcnow().date()
|
||||
start_date = get_midnight_in_utc(today)
|
||||
return (
|
||||
db.session.query(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
func.count(Notification.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
Notification.created_at >= start_date,
|
||||
)
|
||||
.group_by(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
today = date.today()
|
||||
start_date = get_local_midnight_in_utc(today)
|
||||
|
||||
return db.session.query(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
func.count(Notification.id).label('count')
|
||||
).filter(
|
||||
Notification.service_id == service_id,
|
||||
Notification.key_type != KEY_TYPE_TEST,
|
||||
Notification.created_at >= start_date
|
||||
).group_by(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
).all()
|
||||
|
||||
|
||||
def dao_fetch_todays_stats_for_all_services(
|
||||
include_from_test_key=True, only_active=True
|
||||
):
|
||||
today = datetime.utcnow().date()
|
||||
start_date = get_midnight_in_utc(today)
|
||||
end_date = get_midnight_in_utc(today + timedelta(days=1))
|
||||
def dao_fetch_todays_stats_for_all_services(include_from_test_key=True, only_active=True):
|
||||
today = date.today()
|
||||
start_date = get_local_midnight_in_utc(today)
|
||||
end_date = get_local_midnight_in_utc(today + timedelta(days=1))
|
||||
|
||||
subquery = (
|
||||
db.session.query(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
Notification.service_id,
|
||||
func.count(Notification.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
Notification.created_at >= start_date, Notification.created_at < end_date
|
||||
)
|
||||
.group_by(
|
||||
Notification.notification_type, Notification.status, Notification.service_id
|
||||
)
|
||||
subquery = db.session.query(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
Notification.service_id,
|
||||
func.count(Notification.id).label('count')
|
||||
).filter(
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at < end_date
|
||||
).group_by(
|
||||
Notification.notification_type,
|
||||
Notification.status,
|
||||
Notification.service_id
|
||||
)
|
||||
|
||||
if not include_from_test_key:
|
||||
subquery = subquery.filter(Notification.key_type != KeyType.TEST)
|
||||
subquery = subquery.filter(Notification.key_type != KEY_TYPE_TEST)
|
||||
|
||||
subquery = subquery.subquery()
|
||||
|
||||
query = (
|
||||
db.session.query(
|
||||
Service.id.label("service_id"),
|
||||
Service.name,
|
||||
Service.restricted,
|
||||
Service.active,
|
||||
Service.created_at,
|
||||
subquery.c.notification_type,
|
||||
subquery.c.status,
|
||||
subquery.c.count,
|
||||
)
|
||||
.outerjoin(subquery, subquery.c.service_id == Service.id)
|
||||
.order_by(Service.id)
|
||||
)
|
||||
query = db.session.query(
|
||||
Service.id.label('service_id'),
|
||||
Service.name,
|
||||
Service.restricted,
|
||||
Service.research_mode,
|
||||
Service.active,
|
||||
Service.created_at,
|
||||
subquery.c.notification_type,
|
||||
subquery.c.status,
|
||||
subquery.c.count
|
||||
).outerjoin(
|
||||
subquery,
|
||||
subquery.c.service_id == Service.id
|
||||
).order_by(Service.id)
|
||||
|
||||
if only_active:
|
||||
query = query.filter(Service.active)
|
||||
@@ -476,13 +461,9 @@ def dao_fetch_todays_stats_for_all_services(
|
||||
def dao_suspend_service(service_id):
|
||||
# have to eager load api keys so that we don't flush when we loop through them
|
||||
# to ensure that db.session still contains the models when it comes to creating history objects
|
||||
service = (
|
||||
Service.query.options(
|
||||
joinedload("api_keys"),
|
||||
)
|
||||
.filter(Service.id == service_id)
|
||||
.one()
|
||||
)
|
||||
service = Service.query.options(
|
||||
joinedload('api_keys'),
|
||||
).filter(Service.id == service_id).one()
|
||||
|
||||
for api_key in service.api_keys:
|
||||
if not api_key.expiry_date:
|
||||
@@ -499,105 +480,99 @@ def dao_resume_service(service_id):
|
||||
|
||||
|
||||
def dao_fetch_active_users_for_service(service_id):
|
||||
query = User.query.filter(User.services.any(id=service_id), User.state == "active")
|
||||
query = User.query.filter(
|
||||
User.services.any(id=service_id),
|
||||
User.state == 'active'
|
||||
)
|
||||
|
||||
return query.all()
|
||||
|
||||
|
||||
def dao_find_services_sending_to_tv_numbers(start_date, end_date, threshold=500):
|
||||
return (
|
||||
db.session.query(
|
||||
Notification.service_id.label("service_id"),
|
||||
func.count(Notification.id).label("notification_count"),
|
||||
)
|
||||
.filter(
|
||||
Notification.service_id == Service.id,
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at <= end_date,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
Notification.notification_type == NotificationType.SMS,
|
||||
func.substr(Notification.normalised_to, 3, 7) == "7700900",
|
||||
Service.restricted == False, # noqa
|
||||
Service.active == True, # noqa
|
||||
)
|
||||
.group_by(
|
||||
Notification.service_id,
|
||||
)
|
||||
.having(func.count(Notification.id) > threshold)
|
||||
.all()
|
||||
)
|
||||
return db.session.query(
|
||||
Notification.service_id.label('service_id'),
|
||||
func.count(Notification.id).label('notification_count')
|
||||
).filter(
|
||||
Notification.service_id == Service.id,
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at <= end_date,
|
||||
Notification.key_type != KEY_TYPE_TEST,
|
||||
Notification.notification_type == SMS_TYPE,
|
||||
func.substr(Notification.normalised_to, 3, 7) == '7700900',
|
||||
Service.restricted == False, # noqa
|
||||
Service.research_mode == False, # noqa
|
||||
Service.active == True, # noqa
|
||||
).group_by(
|
||||
Notification.service_id,
|
||||
).having(
|
||||
func.count(Notification.id) > threshold
|
||||
).all()
|
||||
|
||||
|
||||
def dao_find_services_with_high_failure_rates(start_date, end_date, threshold=10000):
|
||||
subquery = (
|
||||
db.session.query(
|
||||
func.count(Notification.id).label("total_count"),
|
||||
Notification.service_id.label("service_id"),
|
||||
)
|
||||
.filter(
|
||||
Notification.service_id == Service.id,
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at <= end_date,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
Notification.notification_type == NotificationType.SMS,
|
||||
Service.restricted == False, # noqa
|
||||
Service.active == True, # noqa
|
||||
)
|
||||
.group_by(
|
||||
Notification.service_id,
|
||||
)
|
||||
.having(func.count(Notification.id) >= threshold)
|
||||
subquery = db.session.query(
|
||||
func.count(Notification.id).label('total_count'),
|
||||
Notification.service_id.label('service_id')
|
||||
).filter(
|
||||
Notification.service_id == Service.id,
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at <= end_date,
|
||||
Notification.key_type != KEY_TYPE_TEST,
|
||||
Notification.notification_type == SMS_TYPE,
|
||||
Service.restricted == False, # noqa
|
||||
Service.research_mode == False, # noqa
|
||||
Service.active == True, # noqa
|
||||
).group_by(
|
||||
Notification.service_id,
|
||||
).having(
|
||||
func.count(Notification.id) >= threshold
|
||||
)
|
||||
|
||||
subquery = subquery.subquery()
|
||||
|
||||
query = (
|
||||
db.session.query(
|
||||
Notification.service_id.label("service_id"),
|
||||
func.count(Notification.id).label("permanent_failure_count"),
|
||||
subquery.c.total_count.label("total_count"),
|
||||
(
|
||||
cast(func.count(Notification.id), Float)
|
||||
/ cast(subquery.c.total_count, Float)
|
||||
).label("permanent_failure_rate"),
|
||||
)
|
||||
.join(subquery, subquery.c.service_id == Notification.service_id)
|
||||
.filter(
|
||||
Notification.service_id == Service.id,
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at <= end_date,
|
||||
Notification.key_type != KeyType.TEST,
|
||||
Notification.notification_type == NotificationType.SMS,
|
||||
Notification.status == NotificationStatus.PERMANENT_FAILURE,
|
||||
Service.restricted == False, # noqa
|
||||
Service.active == True, # noqa
|
||||
)
|
||||
.group_by(Notification.service_id, subquery.c.total_count)
|
||||
.having(
|
||||
cast(func.count(Notification.id), Float)
|
||||
/ cast(subquery.c.total_count, Float)
|
||||
>= 0.25
|
||||
)
|
||||
query = db.session.query(
|
||||
Notification.service_id.label('service_id'),
|
||||
func.count(Notification.id).label('permanent_failure_count'),
|
||||
subquery.c.total_count.label('total_count'),
|
||||
(cast(func.count(Notification.id), Float) / cast(subquery.c.total_count, Float)).label('permanent_failure_rate')
|
||||
).join(
|
||||
subquery,
|
||||
subquery.c.service_id == Notification.service_id
|
||||
).filter(
|
||||
Notification.service_id == Service.id,
|
||||
Notification.created_at >= start_date,
|
||||
Notification.created_at <= end_date,
|
||||
Notification.key_type != KEY_TYPE_TEST,
|
||||
Notification.notification_type == SMS_TYPE,
|
||||
Notification.status == NOTIFICATION_PERMANENT_FAILURE,
|
||||
Service.restricted == False, # noqa
|
||||
Service.research_mode == False, # noqa
|
||||
Service.active == True, # noqa
|
||||
).group_by(
|
||||
Notification.service_id,
|
||||
subquery.c.total_count
|
||||
).having(
|
||||
cast(func.count(Notification.id), Float) / cast(subquery.c.total_count, Float) >= 0.25
|
||||
)
|
||||
|
||||
return query.all()
|
||||
|
||||
|
||||
def get_live_services_with_organization():
|
||||
query = (
|
||||
db.session.query(
|
||||
Service.id.label("service_id"),
|
||||
Service.name.label("service_name"),
|
||||
Organization.id.label("organization_id"),
|
||||
Organization.name.label("organization_name"),
|
||||
)
|
||||
.outerjoin(Service.organization)
|
||||
.filter(
|
||||
Service.count_as_live.is_(True),
|
||||
Service.active.is_(True),
|
||||
Service.restricted.is_(False),
|
||||
)
|
||||
.order_by(Organization.name, Service.name)
|
||||
def get_live_services_with_organisation():
|
||||
query = db.session.query(
|
||||
Service.id.label("service_id"),
|
||||
Service.name.label("service_name"),
|
||||
Organisation.id.label("organisation_id"),
|
||||
Organisation.name.label("organisation_name")
|
||||
).outerjoin(
|
||||
Service.organisation
|
||||
).filter(
|
||||
Service.count_as_live.is_(True),
|
||||
Service.active.is_(True),
|
||||
Service.restricted.is_(False)
|
||||
).order_by(
|
||||
Organisation.name,
|
||||
Service.name
|
||||
)
|
||||
|
||||
return query.all()
|
||||
|
||||
@@ -5,7 +5,8 @@ from app.models import TemplateFolder
|
||||
|
||||
def dao_get_template_folder_by_id_and_service_id(template_folder_id, service_id):
|
||||
return TemplateFolder.query.filter(
|
||||
TemplateFolder.id == template_folder_id, TemplateFolder.service_id == service_id
|
||||
TemplateFolder.id == template_folder_id,
|
||||
TemplateFolder.service_id == service_id
|
||||
).one()
|
||||
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ from app.models import Template, TemplateHistory, TemplateRedacted
|
||||
|
||||
|
||||
@autocommit
|
||||
@version_class(VersionOptions(Template, history_class=TemplateHistory))
|
||||
@version_class(
|
||||
VersionOptions(Template, history_class=TemplateHistory)
|
||||
)
|
||||
def dao_create_template(template):
|
||||
template.id = (
|
||||
uuid.uuid4()
|
||||
) # must be set now so version history model can use same id
|
||||
template.id = uuid.uuid4() # must be set now so version history model can use same id
|
||||
template.archived = False
|
||||
|
||||
redacted_dict = {
|
||||
@@ -31,7 +31,9 @@ def dao_create_template(template):
|
||||
|
||||
|
||||
@autocommit
|
||||
@version_class(VersionOptions(Template, history_class=TemplateHistory))
|
||||
@version_class(
|
||||
VersionOptions(Template, history_class=TemplateHistory)
|
||||
)
|
||||
def dao_update_template(template):
|
||||
db.session.add(template)
|
||||
|
||||
@@ -47,52 +49,47 @@ def dao_redact_template(template, user_id):
|
||||
def dao_get_template_by_id_and_service_id(template_id, service_id, version=None):
|
||||
if version is not None:
|
||||
return TemplateHistory.query.filter_by(
|
||||
id=template_id, hidden=False, service_id=service_id, version=version
|
||||
).one()
|
||||
return Template.query.filter_by(
|
||||
id=template_id, hidden=False, service_id=service_id
|
||||
).one()
|
||||
id=template_id,
|
||||
hidden=False,
|
||||
service_id=service_id,
|
||||
version=version).one()
|
||||
return Template.query.filter_by(id=template_id, hidden=False, service_id=service_id).one()
|
||||
|
||||
|
||||
def dao_get_template_by_id(template_id, version=None):
|
||||
if version is not None:
|
||||
return TemplateHistory.query.filter_by(id=template_id, version=version).one()
|
||||
return TemplateHistory.query.filter_by(
|
||||
id=template_id,
|
||||
version=version).one()
|
||||
return Template.query.filter_by(id=template_id).one()
|
||||
|
||||
|
||||
def dao_get_all_templates_for_service(service_id, template_type=None):
|
||||
if template_type is not None:
|
||||
return (
|
||||
Template.query.filter_by(
|
||||
service_id=service_id,
|
||||
template_type=template_type,
|
||||
hidden=False,
|
||||
archived=False,
|
||||
)
|
||||
.order_by(
|
||||
asc(Template.name),
|
||||
asc(Template.template_type),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return (
|
||||
Template.query.filter_by(service_id=service_id, hidden=False, archived=False)
|
||||
.order_by(
|
||||
return Template.query.filter_by(
|
||||
service_id=service_id,
|
||||
template_type=template_type,
|
||||
hidden=False,
|
||||
archived=False
|
||||
).order_by(
|
||||
asc(Template.name),
|
||||
asc(Template.template_type),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
).all()
|
||||
|
||||
return Template.query.filter_by(
|
||||
service_id=service_id,
|
||||
hidden=False,
|
||||
archived=False
|
||||
).order_by(
|
||||
asc(Template.name),
|
||||
asc(Template.template_type),
|
||||
).all()
|
||||
|
||||
|
||||
def dao_get_template_versions(service_id, template_id):
|
||||
return (
|
||||
TemplateHistory.query.filter_by(
|
||||
service_id=service_id,
|
||||
id=template_id,
|
||||
hidden=False,
|
||||
)
|
||||
.order_by(desc(TemplateHistory.version))
|
||||
.all()
|
||||
)
|
||||
return TemplateHistory.query.filter_by(
|
||||
service_id=service_id, id=template_id,
|
||||
hidden=False,
|
||||
).order_by(
|
||||
desc(TemplateHistory.version)
|
||||
).all()
|
||||
|
||||
@@ -5,22 +5,27 @@ from flask import current_app
|
||||
from sqlalchemy import String, and_, desc, func, literal, text
|
||||
|
||||
from app import db
|
||||
from app.enums import JobStatus, NotificationStatus, NotificationType
|
||||
from app.models import Job, Notification, ServiceDataRetention, Template
|
||||
from app.models import (
|
||||
JOB_STATUS_CANCELLED,
|
||||
JOB_STATUS_SCHEDULED,
|
||||
LETTER_TYPE,
|
||||
NOTIFICATION_CANCELLED,
|
||||
Job,
|
||||
Notification,
|
||||
ServiceDataRetention,
|
||||
Template,
|
||||
)
|
||||
from app.utils import midnight_n_days_ago
|
||||
|
||||
|
||||
def _get_printing_day(created_at):
|
||||
return func.date_trunc(
|
||||
"day",
|
||||
func.timezone(
|
||||
getenv("TIMEZONE", "America/New_York"), func.timezone("UTC", created_at)
|
||||
)
|
||||
+ text(
|
||||
'day',
|
||||
func.timezone(getenv("TIMEZONE", "America/New_York"), func.timezone('UTC', created_at)) + text(
|
||||
# We add 6 hours 30 minutes to the local created_at time so that
|
||||
# any letters created after 5:30pm get shifted into the next day
|
||||
"interval '6 hours 30 minutes'"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -32,9 +37,7 @@ def _get_printing_datetime(created_at):
|
||||
|
||||
|
||||
def _naive_gmt_to_utc(column):
|
||||
return func.timezone(
|
||||
"UTC", func.timezone(getenv("TIMEZONE", "America/New_York"), column)
|
||||
)
|
||||
return func.timezone('UTC', func.timezone(getenv("TIMEZONE", "America/New_York"), column))
|
||||
|
||||
|
||||
def dao_get_uploads_by_service_id(service_id, limit_days=None, page=1, page_size=50):
|
||||
@@ -43,97 +46,86 @@ def dao_get_uploads_by_service_id(service_id, limit_days=None, page=1, page_size
|
||||
today = datetime.utcnow().date()
|
||||
jobs_query_filter = [
|
||||
Job.service_id == service_id,
|
||||
Job.original_file_name != current_app.config["TEST_MESSAGE_FILENAME"],
|
||||
Job.original_file_name != current_app.config["ONE_OFF_MESSAGE_FILENAME"],
|
||||
Job.job_status.notin_([JobStatus.CANCELLED, JobStatus.SCHEDULED]),
|
||||
func.coalesce(Job.processing_started, Job.created_at)
|
||||
>= today - func.coalesce(ServiceDataRetention.days_of_retention, 7),
|
||||
Job.original_file_name != current_app.config['TEST_MESSAGE_FILENAME'],
|
||||
Job.original_file_name != current_app.config['ONE_OFF_MESSAGE_FILENAME'],
|
||||
Job.job_status.notin_([JOB_STATUS_CANCELLED, JOB_STATUS_SCHEDULED]),
|
||||
func.coalesce(
|
||||
Job.processing_started, Job.created_at
|
||||
) >= today - func.coalesce(ServiceDataRetention.days_of_retention, 7),
|
||||
Job.contact_list_id.is_(None),
|
||||
]
|
||||
if limit_days is not None:
|
||||
jobs_query_filter.append(Job.created_at >= midnight_n_days_ago(limit_days))
|
||||
|
||||
jobs_query = (
|
||||
db.session.query(
|
||||
Job.id,
|
||||
Job.original_file_name,
|
||||
Job.notification_count,
|
||||
Template.template_type,
|
||||
func.coalesce(ServiceDataRetention.days_of_retention, 7).label(
|
||||
"days_of_retention"
|
||||
),
|
||||
Job.created_at.label("created_at"),
|
||||
Job.scheduled_for.label("scheduled_for"),
|
||||
Job.processing_started.label("processing_started"),
|
||||
Job.job_status.label("status"),
|
||||
literal("job").label("upload_type"),
|
||||
literal(None).label("recipient"),
|
||||
jobs_query = db.session.query(
|
||||
Job.id,
|
||||
Job.original_file_name,
|
||||
Job.notification_count,
|
||||
Template.template_type,
|
||||
func.coalesce(ServiceDataRetention.days_of_retention, 7).label('days_of_retention'),
|
||||
Job.created_at.label("created_at"),
|
||||
Job.scheduled_for.label("scheduled_for"),
|
||||
Job.processing_started.label('processing_started'),
|
||||
Job.job_status.label("status"),
|
||||
literal('job').label('upload_type'),
|
||||
literal(None).label('recipient'),
|
||||
).join(
|
||||
Template, Job.template_id == Template.id
|
||||
).outerjoin(
|
||||
ServiceDataRetention, and_(
|
||||
Template.service_id == ServiceDataRetention.service_id,
|
||||
func.cast(Template.template_type, String) == func.cast(ServiceDataRetention.notification_type, String)
|
||||
)
|
||||
.join(Template, Job.template_id == Template.id)
|
||||
.outerjoin(
|
||||
ServiceDataRetention,
|
||||
and_(
|
||||
Template.service_id == ServiceDataRetention.service_id,
|
||||
func.cast(Template.template_type, String)
|
||||
== func.cast(ServiceDataRetention.notification_type, String),
|
||||
),
|
||||
)
|
||||
.filter(*jobs_query_filter)
|
||||
).filter(
|
||||
*jobs_query_filter
|
||||
)
|
||||
|
||||
letters_query_filter = [
|
||||
Notification.service_id == service_id,
|
||||
Notification.notification_type == NotificationType.LETTER,
|
||||
Notification.notification_type == LETTER_TYPE,
|
||||
Notification.api_key_id == None, # noqa
|
||||
Notification.status != NotificationStatus.CANCELLED,
|
||||
Notification.status != NOTIFICATION_CANCELLED,
|
||||
Template.hidden == True, # noqa
|
||||
Notification.created_at
|
||||
>= today - func.coalesce(ServiceDataRetention.days_of_retention, 7),
|
||||
Notification.created_at >= today - func.coalesce(ServiceDataRetention.days_of_retention, 7)
|
||||
]
|
||||
if limit_days is not None:
|
||||
letters_query_filter.append(
|
||||
Notification.created_at >= midnight_n_days_ago(limit_days)
|
||||
)
|
||||
letters_query_filter.append(Notification.created_at >= midnight_n_days_ago(limit_days))
|
||||
|
||||
letters_subquery = (
|
||||
db.session.query(
|
||||
func.count().label("notification_count"),
|
||||
_naive_gmt_to_utc(_get_printing_datetime(Notification.created_at)).label(
|
||||
"printing_at"
|
||||
),
|
||||
letters_subquery = db.session.query(
|
||||
func.count().label('notification_count'),
|
||||
_naive_gmt_to_utc(_get_printing_datetime(Notification.created_at)).label('printing_at'),
|
||||
).join(
|
||||
Template, Notification.template_id == Template.id
|
||||
).outerjoin(
|
||||
ServiceDataRetention, and_(
|
||||
Template.service_id == ServiceDataRetention.service_id,
|
||||
func.cast(Template.template_type, String) == func.cast(ServiceDataRetention.notification_type, String)
|
||||
)
|
||||
.join(Template, Notification.template_id == Template.id)
|
||||
.outerjoin(
|
||||
ServiceDataRetention,
|
||||
and_(
|
||||
Template.service_id == ServiceDataRetention.service_id,
|
||||
func.cast(Template.template_type, String)
|
||||
== func.cast(ServiceDataRetention.notification_type, String),
|
||||
),
|
||||
)
|
||||
.filter(*letters_query_filter)
|
||||
.group_by("printing_at")
|
||||
.subquery()
|
||||
)
|
||||
).filter(
|
||||
*letters_query_filter
|
||||
).group_by(
|
||||
'printing_at'
|
||||
).subquery()
|
||||
|
||||
letters_query = db.session.query(
|
||||
literal(None).label("id"),
|
||||
literal("Uploaded letters").label("original_file_name"),
|
||||
letters_subquery.c.notification_count.label("notification_count"),
|
||||
literal("letter").label("template_type"),
|
||||
literal(None).label("days_of_retention"),
|
||||
letters_subquery.c.printing_at.label("created_at"),
|
||||
literal(None).label("scheduled_for"),
|
||||
letters_subquery.c.printing_at.label("processing_started"),
|
||||
literal(None).label("status"),
|
||||
literal("letter_day").label("upload_type"),
|
||||
literal(None).label("recipient"),
|
||||
literal(None).label('id'),
|
||||
literal('Uploaded letters').label('original_file_name'),
|
||||
letters_subquery.c.notification_count.label('notification_count'),
|
||||
literal('letter').label('template_type'),
|
||||
literal(None).label('days_of_retention'),
|
||||
letters_subquery.c.printing_at.label('created_at'),
|
||||
literal(None).label('scheduled_for'),
|
||||
letters_subquery.c.printing_at.label('processing_started'),
|
||||
literal(None).label('status'),
|
||||
literal('letter_day').label('upload_type'),
|
||||
literal(None).label('recipient'),
|
||||
).group_by(
|
||||
letters_subquery.c.notification_count,
|
||||
letters_subquery.c.printing_at,
|
||||
)
|
||||
|
||||
return (
|
||||
jobs_query.union_all(letters_query)
|
||||
.order_by(desc("processing_started"), desc("created_at"))
|
||||
.paginate(page=page, per_page=page_size)
|
||||
)
|
||||
return jobs_query.union_all(
|
||||
letters_query
|
||||
).order_by(
|
||||
desc("processing_started"), desc("created_at")
|
||||
).paginate(page=page, per_page=page_size)
|
||||
|
||||
@@ -9,9 +9,8 @@ from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.dao.permissions_dao import permission_dao
|
||||
from app.dao.service_user_dao import dao_get_service_users_by_user_id
|
||||
from app.enums import AuthType, PermissionType
|
||||
from app.errors import InvalidRequest
|
||||
from app.models import User, VerifyCode
|
||||
from app.models import EMAIL_AUTH_TYPE, User, VerifyCode
|
||||
from app.utils import escape_special_characters, get_archived_db_column_value
|
||||
|
||||
|
||||
@@ -21,53 +20,23 @@ def _remove_values_for_keys_if_present(dict, keys):
|
||||
|
||||
|
||||
def create_secret_code(length=6):
|
||||
random_number = randbelow(10**length)
|
||||
random_number = randbelow(10 ** length)
|
||||
return "{:0{length}d}".format(random_number, length=length)
|
||||
|
||||
|
||||
def get_login_gov_user(login_uuid, email_address):
|
||||
"""
|
||||
We want to check to see if the user is registered with login.gov
|
||||
If we can find the login.gov uuid in our user table, then they are.
|
||||
|
||||
Also, because we originally keyed off email address we might have a few
|
||||
older users who registered with login.gov but we don't know what their
|
||||
login.gov uuids are. Eventually the code that checks by email address
|
||||
should be removed.
|
||||
"""
|
||||
|
||||
user = User.query.filter_by(login_uuid=login_uuid).first()
|
||||
if user:
|
||||
if user.email_address != email_address:
|
||||
save_user_attribute(user, {"email_address": email_address})
|
||||
return user
|
||||
# Remove this 1 July 2025, all users should have login.gov uuids by now
|
||||
user = User.query.filter_by(email_address=email_address).first()
|
||||
if user:
|
||||
save_user_attribute(user, {"login_uuid": login_uuid})
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def save_user_attribute(usr, update_dict=None):
|
||||
db.session.query(User).filter_by(id=usr.id).update(update_dict or {})
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def save_model_user(
|
||||
user,
|
||||
update_dict=None,
|
||||
password=None,
|
||||
validated_email_access=False,
|
||||
):
|
||||
def save_model_user(user, update_dict=None, password=None, validated_email_access=False):
|
||||
if password:
|
||||
user.password = password
|
||||
user.password_changed_at = datetime.utcnow()
|
||||
if validated_email_access:
|
||||
user.email_access_validated_at = datetime.utcnow()
|
||||
if update_dict:
|
||||
_remove_values_for_keys_if_present(update_dict, ["id", "password_changed_at"])
|
||||
_remove_values_for_keys_if_present(update_dict, ['id', 'password_changed_at'])
|
||||
db.session.query(User).filter_by(id=user.id).update(update_dict or {})
|
||||
else:
|
||||
db.session.add(user)
|
||||
@@ -75,11 +44,9 @@ def save_model_user(
|
||||
|
||||
|
||||
def create_user_code(user, code, code_type):
|
||||
verify_code = VerifyCode(
|
||||
code_type=code_type,
|
||||
expiry_datetime=datetime.utcnow() + timedelta(minutes=30),
|
||||
user=user,
|
||||
)
|
||||
verify_code = VerifyCode(code_type=code_type,
|
||||
expiry_datetime=datetime.utcnow() + timedelta(minutes=30),
|
||||
user=user)
|
||||
verify_code.code = code
|
||||
db.session.add(verify_code)
|
||||
db.session.commit()
|
||||
@@ -89,18 +56,16 @@ def create_user_code(user, code, code_type):
|
||||
def get_user_code(user, code, code_type):
|
||||
# Get the most recent codes to try and reduce the
|
||||
# time searching for the correct code.
|
||||
codes = VerifyCode.query.filter_by(user=user, code_type=code_type).order_by(
|
||||
VerifyCode.created_at.desc()
|
||||
)
|
||||
codes = VerifyCode.query.filter_by(
|
||||
user=user, code_type=code_type).order_by(
|
||||
VerifyCode.created_at.desc())
|
||||
return next((x for x in codes if x.check_code(code)), None)
|
||||
|
||||
|
||||
def delete_codes_older_created_more_than_a_day_ago():
|
||||
deleted = (
|
||||
db.session.query(VerifyCode)
|
||||
.filter(VerifyCode.created_at < datetime.utcnow() - timedelta(hours=24))
|
||||
.delete()
|
||||
)
|
||||
deleted = db.session.query(VerifyCode).filter(
|
||||
VerifyCode.created_at < datetime.utcnow() - timedelta(hours=24)
|
||||
).delete()
|
||||
db.session.commit()
|
||||
return deleted
|
||||
|
||||
@@ -126,7 +91,7 @@ def count_user_verify_codes(user):
|
||||
query = VerifyCode.query.filter(
|
||||
VerifyCode.user == user,
|
||||
VerifyCode.expiry_datetime > datetime.utcnow(),
|
||||
VerifyCode.code_used.is_(False),
|
||||
VerifyCode.code_used.is_(False)
|
||||
)
|
||||
return query.count()
|
||||
|
||||
@@ -137,10 +102,6 @@ def get_user_by_id(user_id=None):
|
||||
return User.query.filter_by().all()
|
||||
|
||||
|
||||
def get_users():
|
||||
return User.query.all()
|
||||
|
||||
|
||||
def get_user_by_email(email):
|
||||
return User.query.filter(func.lower(User.email_address) == func.lower(email)).one()
|
||||
|
||||
@@ -172,18 +133,16 @@ def update_user_password(user, password):
|
||||
|
||||
|
||||
def get_user_and_accounts(user_id):
|
||||
return (
|
||||
User.query.filter(User.id == user_id)
|
||||
.options(
|
||||
# eagerly load the user's services and organizations, and also the service's org and vice versa
|
||||
# (so we can see if the user knows about it)
|
||||
joinedload("services"),
|
||||
joinedload("organizations"),
|
||||
joinedload("organizations.services"),
|
||||
joinedload("services.organization"),
|
||||
)
|
||||
.one()
|
||||
)
|
||||
return User.query.filter(
|
||||
User.id == user_id
|
||||
).options(
|
||||
# eagerly load the user's services and organisations, and also the service's org and vice versa
|
||||
# (so we can see if the user knows about it)
|
||||
joinedload('services'),
|
||||
joinedload('organisations'),
|
||||
joinedload('organisations.services'),
|
||||
joinedload('services.organisation'),
|
||||
).one()
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -198,15 +157,15 @@ def dao_archive_user(user):
|
||||
for service_user in service_users:
|
||||
db.session.delete(service_user)
|
||||
|
||||
user.organizations = []
|
||||
user.organisations = []
|
||||
|
||||
user.auth_type = AuthType.EMAIL
|
||||
user.auth_type = EMAIL_AUTH_TYPE
|
||||
user.email_address = get_archived_db_column_value(user.email_address)
|
||||
user.mobile_number = None
|
||||
user.password = str(uuid.uuid4())
|
||||
# Changing the current_session_id signs the user out
|
||||
user.current_session_id = "00000000-0000-0000-0000-000000000000"
|
||||
user.state = "inactive"
|
||||
user.current_session_id = '00000000-0000-0000-0000-000000000000'
|
||||
user.state = 'inactive'
|
||||
|
||||
db.session.add(user)
|
||||
|
||||
@@ -215,17 +174,12 @@ def user_can_be_archived(user):
|
||||
active_services = [x for x in user.services if x.active]
|
||||
|
||||
for service in active_services:
|
||||
other_active_users = [
|
||||
x for x in service.users if x.state == "active" and x != user
|
||||
]
|
||||
other_active_users = [x for x in service.users if x.state == 'active' and x != user]
|
||||
|
||||
if not other_active_users:
|
||||
return False
|
||||
|
||||
if not any(
|
||||
PermissionType.MANAGE_SETTINGS in user.get_permissions(service.id)
|
||||
for user in other_active_users
|
||||
):
|
||||
if not any('manage_settings' in user.get_permissions(service.id) for user in other_active_users):
|
||||
# no-one else has manage settings
|
||||
return False
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.models import WebauthnCredential
|
||||
def dao_get_webauthn_credential_by_user_and_id(user_id, webauthn_credential_id):
|
||||
return WebauthnCredential.query.filter(
|
||||
WebauthnCredential.user_id == user_id,
|
||||
WebauthnCredential.id == webauthn_credential_id,
|
||||
WebauthnCredential.id == webauthn_credential_id
|
||||
).one()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def dao_create_webauthn_credential(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
credential_data=credential_data,
|
||||
registration_response=registration_response,
|
||||
registration_response=registration_response
|
||||
)
|
||||
db.session.add(webauthn_credential)
|
||||
return webauthn_credential
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from urllib import parse
|
||||
|
||||
from cachetools import TTLCache, cached
|
||||
@@ -10,52 +10,47 @@ from notifications_utils.template import (
|
||||
SMSMessageTemplate,
|
||||
)
|
||||
|
||||
from app import create_uuid, db, notification_provider_clients, redis_store
|
||||
from app.aws.s3 import get_personalisation_from_s3, get_phone_number_from_s3
|
||||
from app.celery.test_key_tasks import send_email_response, send_sms_response
|
||||
from app import create_uuid, db, notification_provider_clients, statsd_client
|
||||
from app.celery.research_mode_tasks import (
|
||||
send_email_response,
|
||||
send_sms_response,
|
||||
)
|
||||
from app.dao.email_branding_dao import dao_get_email_branding_by_id
|
||||
from app.dao.notifications_dao import dao_update_notification
|
||||
from app.dao.provider_details_dao import get_provider_details_by_notification_type
|
||||
from app.enums import BrandType, KeyType, NotificationStatus, NotificationType
|
||||
from app.dao.provider_details_dao import (
|
||||
dao_reduce_sms_provider_priority,
|
||||
get_provider_details_by_notification_type,
|
||||
)
|
||||
from app.exceptions import NotificationTechnicalFailureException
|
||||
from app.models import (
|
||||
BRANDING_BOTH,
|
||||
BRANDING_ORG_BANNER,
|
||||
EMAIL_TYPE,
|
||||
KEY_TYPE_TEST,
|
||||
NOTIFICATION_SENDING,
|
||||
NOTIFICATION_SENT,
|
||||
NOTIFICATION_STATUS_TYPES_COMPLETED,
|
||||
NOTIFICATION_TECHNICAL_FAILURE,
|
||||
SMS_TYPE,
|
||||
)
|
||||
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
|
||||
# so if there is some value there, don't overwrite it
|
||||
if not notification.personalisation:
|
||||
personalisation = get_personalisation_from_s3(
|
||||
notification.service_id,
|
||||
notification.job_id,
|
||||
notification.job_row_number,
|
||||
)
|
||||
notification.personalisation = personalisation
|
||||
|
||||
service = SerialisedService.from_id(notification.service_id)
|
||||
message_id = None
|
||||
|
||||
if not service.active:
|
||||
technical_failure(notification=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 notification.status == 'created':
|
||||
provider = provider_to_use(SMS_TYPE, notification.international)
|
||||
if not provider:
|
||||
technical_failure(notification=notification)
|
||||
return
|
||||
|
||||
template_model = SerialisedTemplate.from_id_and_service_id(
|
||||
template_id=notification.template_id,
|
||||
service_id=service.id,
|
||||
version=notification.template_version,
|
||||
template_id=notification.template_id, service_id=service.id, version=notification.template_version
|
||||
)
|
||||
|
||||
template = SMSMessageTemplate(
|
||||
@@ -64,9 +59,11 @@ def send_sms_to_provider(notification):
|
||||
prefix=service.name,
|
||||
show_prefix=service.prefix_sms,
|
||||
)
|
||||
if notification.key_type == KeyType.TEST:
|
||||
created_at = notification.created_at
|
||||
key_type = notification.key_type
|
||||
if service.research_mode or notification.key_type == KEY_TYPE_TEST:
|
||||
update_notification_to_sending(notification, provider)
|
||||
send_sms_response(provider.name, str(notification.id))
|
||||
send_sms_response(provider.name, str(notification.id), notification.to)
|
||||
|
||||
else:
|
||||
try:
|
||||
@@ -76,111 +73,99 @@ def send_sms_to_provider(notification):
|
||||
# providers as a slow down of our providers can cause us to run out of DB connections
|
||||
# Therefore we pull all the data from our DB models into `send_sms_kwargs`now before
|
||||
# closing the session (as otherwise it would be reopened immediately)
|
||||
|
||||
# We start by trying to get the phone number from a job in s3. If we fail, we assume
|
||||
# the phone number is for the verification code on login, which is not a job.
|
||||
recipient = None
|
||||
try:
|
||||
recipient = get_phone_number_from_s3(
|
||||
notification.service_id,
|
||||
notification.job_id,
|
||||
notification.job_row_number,
|
||||
)
|
||||
except Exception:
|
||||
# It is our 2facode, maybe
|
||||
key = f"2facode-{notification.id}".replace(" ", "")
|
||||
recipient = redis_store.raw_get(key)
|
||||
|
||||
if recipient:
|
||||
recipient = recipient.decode("utf-8")
|
||||
|
||||
if recipient is None:
|
||||
si = notification.service_id
|
||||
ji = notification.job_id
|
||||
jrn = notification.job_row_number
|
||||
raise Exception(
|
||||
f"The recipient for (Service ID: {si}; Job ID: {ji}; Job Row Number {jrn} was not found."
|
||||
)
|
||||
send_sms_kwargs = {
|
||||
"to": recipient,
|
||||
"content": str(template),
|
||||
"reference": str(notification.id),
|
||||
"sender": notification.reply_to_text,
|
||||
"international": notification.international,
|
||||
'to': notification.normalised_to,
|
||||
'content': str(template),
|
||||
'reference': str(notification.id),
|
||||
'sender': notification.reply_to_text,
|
||||
'international': notification.international,
|
||||
}
|
||||
db.session.close() # no commit needed as no changes to objects have been made above
|
||||
current_app.logger.info("sending to sms")
|
||||
message_id = provider.send_sms(**send_sms_kwargs)
|
||||
current_app.logger.info(f"got message_id {message_id}")
|
||||
provider.send_sms(**send_sms_kwargs)
|
||||
except Exception as e:
|
||||
current_app.logger.error(e)
|
||||
notification.billable_units = template.fragment_count
|
||||
dao_update_notification(notification)
|
||||
dao_reduce_sms_provider_priority(provider.name, time_threshold=timedelta(minutes=1))
|
||||
raise e
|
||||
else:
|
||||
notification.billable_units = template.fragment_count
|
||||
update_notification_to_sending(notification, provider)
|
||||
return message_id
|
||||
|
||||
delta_seconds = (datetime.utcnow() - created_at).total_seconds()
|
||||
statsd_client.timing("sms.total-time", delta_seconds)
|
||||
|
||||
if key_type == KEY_TYPE_TEST:
|
||||
statsd_client.timing("sms.test-key.total-time", delta_seconds)
|
||||
else:
|
||||
statsd_client.timing("sms.live-key.total-time", delta_seconds)
|
||||
if service.high_volume:
|
||||
statsd_client.timing("sms.live-key.high-volume.total-time", delta_seconds)
|
||||
else:
|
||||
statsd_client.timing("sms.live-key.not-high-volume.total-time", delta_seconds)
|
||||
|
||||
|
||||
def send_email_to_provider(notification):
|
||||
# Someone needs an email, possibly new registration
|
||||
recipient = redis_store.get(f"email-address-{notification.id}")
|
||||
recipient = recipient.decode("utf-8")
|
||||
personalisation = redis_store.get(f"email-personalisation-{notification.id}")
|
||||
if personalisation:
|
||||
personalisation = personalisation.decode("utf-8")
|
||||
notification.personalisation = json.loads(personalisation)
|
||||
|
||||
service = SerialisedService.from_id(notification.service_id)
|
||||
|
||||
if not service.active:
|
||||
technical_failure(notification=notification)
|
||||
return
|
||||
if notification.status == NotificationStatus.CREATED:
|
||||
provider = provider_to_use(NotificationType.EMAIL, False)
|
||||
if notification.status == 'created':
|
||||
provider = provider_to_use(EMAIL_TYPE, False)
|
||||
|
||||
template_dict = SerialisedTemplate.from_id_and_service_id(
|
||||
template_id=notification.template_id,
|
||||
service_id=service.id,
|
||||
version=notification.template_version,
|
||||
template_id=notification.template_id, service_id=service.id, version=notification.template_version
|
||||
).__dict__
|
||||
|
||||
html_email = HTMLEmailTemplate(
|
||||
template_dict,
|
||||
values=notification.personalisation,
|
||||
**get_html_email_options(service),
|
||||
**get_html_email_options(service)
|
||||
)
|
||||
|
||||
plain_text_email = PlainTextEmailTemplate(
|
||||
template_dict, values=notification.personalisation
|
||||
template_dict,
|
||||
values=notification.personalisation
|
||||
)
|
||||
|
||||
if notification.key_type == KeyType.TEST:
|
||||
created_at = notification.created_at
|
||||
key_type = notification.key_type
|
||||
if service.research_mode or notification.key_type == KEY_TYPE_TEST:
|
||||
notification.reference = str(create_uuid())
|
||||
update_notification_to_sending(notification, provider)
|
||||
send_email_response(notification.reference, recipient)
|
||||
send_email_response(notification.reference, notification.to)
|
||||
else:
|
||||
from_address = (
|
||||
f'"{service.name}" <{service.email_from}@'
|
||||
f'{current_app.config["NOTIFY_EMAIL_DOMAIN"]}>'
|
||||
)
|
||||
from_address = '"{}" <{}@{}>'.format(service.name, service.email_from,
|
||||
current_app.config['NOTIFY_EMAIL_DOMAIN'])
|
||||
|
||||
reference = provider.send_email(
|
||||
from_address,
|
||||
recipient,
|
||||
notification.normalised_to,
|
||||
plain_text_email.subject,
|
||||
body=str(plain_text_email),
|
||||
html_body=str(html_email),
|
||||
reply_to_address=notification.reply_to_text,
|
||||
reply_to_address=notification.reply_to_text
|
||||
)
|
||||
notification.reference = reference
|
||||
update_notification_to_sending(notification, provider)
|
||||
delta_seconds = (datetime.utcnow() - created_at).total_seconds()
|
||||
|
||||
if key_type == KEY_TYPE_TEST:
|
||||
statsd_client.timing("email.test-key.total-time", delta_seconds)
|
||||
else:
|
||||
statsd_client.timing("email.live-key.total-time", delta_seconds)
|
||||
if service.high_volume:
|
||||
statsd_client.timing("email.live-key.high-volume.total-time", delta_seconds)
|
||||
else:
|
||||
statsd_client.timing("email.live-key.not-high-volume.total-time", delta_seconds)
|
||||
|
||||
|
||||
def update_notification_to_sending(notification, provider):
|
||||
notification.sent_at = datetime.utcnow()
|
||||
notification.sent_by = provider.name
|
||||
if notification.status not in NotificationStatus.completed_types():
|
||||
notification.status = NotificationStatus.SENDING
|
||||
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
|
||||
|
||||
dao_update_notification(notification)
|
||||
|
||||
@@ -191,42 +176,43 @@ provider_cache = TTLCache(maxsize=8, ttl=10)
|
||||
@cached(cache=provider_cache)
|
||||
def provider_to_use(notification_type, international=True):
|
||||
active_providers = [
|
||||
p
|
||||
for p in get_provider_details_by_notification_type(
|
||||
p for p in get_provider_details_by_notification_type(
|
||||
notification_type, international
|
||||
)
|
||||
if p.active
|
||||
) if p.active
|
||||
]
|
||||
|
||||
if not active_providers:
|
||||
current_app.logger.error(f"{notification_type} failed as no active providers")
|
||||
raise Exception(f"No active {notification_type} providers")
|
||||
current_app.logger.error(
|
||||
"{} failed as no active providers".format(notification_type)
|
||||
)
|
||||
raise Exception("No active {} providers".format(notification_type))
|
||||
|
||||
# we only have sns
|
||||
chosen_provider = active_providers[0]
|
||||
if len(active_providers) == 1:
|
||||
chosen_provider = active_providers[0]
|
||||
else:
|
||||
weights = [p.priority for p in active_providers]
|
||||
chosen_provider = random.choices(active_providers, weights=weights)[0] # nosec B311 - not sec/crypto related
|
||||
|
||||
return notification_provider_clients.get_client_by_name_and_type(
|
||||
chosen_provider.identifier, notification_type
|
||||
)
|
||||
return notification_provider_clients.get_client_by_name_and_type(chosen_provider.identifier, notification_type)
|
||||
|
||||
|
||||
def get_logo_url(base_url, logo_file):
|
||||
base_url = parse.urlparse(base_url)
|
||||
netloc = base_url.netloc
|
||||
|
||||
if base_url.netloc.startswith("localhost"):
|
||||
netloc = "notify.tools"
|
||||
elif base_url.netloc.startswith("www"):
|
||||
if base_url.netloc.startswith('localhost'):
|
||||
netloc = 'notify.tools'
|
||||
elif base_url.netloc.startswith('www'):
|
||||
# strip "www."
|
||||
netloc = base_url.netloc[4:]
|
||||
|
||||
logo_url = parse.ParseResult(
|
||||
scheme=base_url.scheme,
|
||||
netloc="static-logos." + netloc,
|
||||
netloc='static-logos.' + netloc,
|
||||
path=logo_file,
|
||||
params=base_url.params,
|
||||
query=base_url.query,
|
||||
fragment=base_url.fragment,
|
||||
fragment=base_url.fragment
|
||||
)
|
||||
return parse.urlunparse(logo_url)
|
||||
|
||||
@@ -234,34 +220,34 @@ def get_logo_url(base_url, logo_file):
|
||||
def get_html_email_options(service):
|
||||
if service.email_branding is None:
|
||||
return {
|
||||
"govuk_banner": True,
|
||||
"brand_banner": False,
|
||||
'govuk_banner': True,
|
||||
'brand_banner': False,
|
||||
}
|
||||
if isinstance(service, SerialisedService):
|
||||
branding = dao_get_email_branding_by_id(service.email_branding)
|
||||
else:
|
||||
branding = service.email_branding
|
||||
|
||||
logo_url = (
|
||||
get_logo_url(current_app.config["ADMIN_BASE_URL"], branding.logo)
|
||||
if branding.logo
|
||||
else None
|
||||
)
|
||||
logo_url = get_logo_url(
|
||||
current_app.config['ADMIN_BASE_URL'],
|
||||
branding.logo
|
||||
) if branding.logo else None
|
||||
|
||||
return {
|
||||
"govuk_banner": branding.brand_type == BrandType.BOTH,
|
||||
"brand_banner": branding.brand_type == BrandType.ORG_BANNER,
|
||||
"brand_colour": branding.colour,
|
||||
"brand_logo": logo_url,
|
||||
"brand_text": branding.text,
|
||||
"brand_name": branding.name,
|
||||
'govuk_banner': branding.brand_type == BRANDING_BOTH,
|
||||
'brand_banner': branding.brand_type == BRANDING_ORG_BANNER,
|
||||
'brand_colour': branding.colour,
|
||||
'brand_logo': logo_url,
|
||||
'brand_text': branding.text,
|
||||
'brand_name': branding.name,
|
||||
}
|
||||
|
||||
|
||||
def technical_failure(notification):
|
||||
notification.status = NotificationStatus.TECHNICAL_FAILURE
|
||||
notification.status = NOTIFICATION_TECHNICAL_FAILURE
|
||||
dao_update_notification(notification)
|
||||
raise NotificationTechnicalFailureException(
|
||||
f"Send {notification.notification_type} for notification id {notification.id} "
|
||||
f"to provider is not allowed: service {notification.service_id} is inactive"
|
||||
)
|
||||
"Send {} for notification id {} to provider is not allowed: service {} is inactive".format(
|
||||
notification.notification_type,
|
||||
notification.id,
|
||||
notification.service_id))
|
||||
|
||||
@@ -2,10 +2,10 @@ from os import path
|
||||
|
||||
from flask import Blueprint, current_app, send_file
|
||||
|
||||
docs = Blueprint("docs", __name__, url_prefix="/docs")
|
||||
docs = Blueprint('docs', __name__, url_prefix='/docs')
|
||||
|
||||
|
||||
@docs.route("/openapi.yml", methods=["GET"])
|
||||
@docs.route('/openapi.yml', methods=['GET'])
|
||||
def send_openapi():
|
||||
openapi_schema = path.join(current_app.root_path, "../docs/openapi.yml")
|
||||
return send_file(openapi_schema, mimetype="text/yaml"), 200
|
||||
openapi_schema = path.join(current_app.root_path, '../docs/openapi.yml')
|
||||
return send_file(openapi_schema, mimetype='text/yaml'), 200
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.enums import BrandType
|
||||
from app.models import BRANDING_TYPES
|
||||
|
||||
post_create_email_branding_schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
@@ -9,9 +9,9 @@ post_create_email_branding_schema = {
|
||||
"name": {"type": "string"},
|
||||
"text": {"type": ["string", "null"]},
|
||||
"logo": {"type": ["string", "null"]},
|
||||
"brand_type": {"enum": list(BrandType)},
|
||||
"brand_type": {"enum": BRANDING_TYPES},
|
||||
},
|
||||
"required": ["name"],
|
||||
"required": ["name"]
|
||||
}
|
||||
|
||||
post_update_email_branding_schema = {
|
||||
@@ -23,7 +23,7 @@ post_update_email_branding_schema = {
|
||||
"name": {"type": ["string", "null"]},
|
||||
"text": {"type": ["string", "null"]},
|
||||
"logo": {"type": ["string", "null"]},
|
||||
"brand_type": {"enum": list(BrandType)},
|
||||
"brand_type": {"enum": BRANDING_TYPES},
|
||||
},
|
||||
"required": [],
|
||||
"required": []
|
||||
}
|
||||
|
||||
@@ -14,45 +14,45 @@ from app.errors import register_errors
|
||||
from app.models import EmailBranding
|
||||
from app.schema_validation import validate
|
||||
|
||||
email_branding_blueprint = Blueprint("email_branding", __name__)
|
||||
email_branding_blueprint = Blueprint('email_branding', __name__)
|
||||
register_errors(email_branding_blueprint)
|
||||
|
||||
|
||||
@email_branding_blueprint.route("", methods=["GET"])
|
||||
@email_branding_blueprint.route('', methods=['GET'])
|
||||
def get_email_branding_options():
|
||||
email_branding_options = [o.serialize() for o in dao_get_email_branding_options()]
|
||||
return jsonify(email_branding=email_branding_options)
|
||||
|
||||
|
||||
@email_branding_blueprint.route("/<uuid:email_branding_id>", methods=["GET"])
|
||||
@email_branding_blueprint.route('/<uuid:email_branding_id>', methods=['GET'])
|
||||
def get_email_branding_by_id(email_branding_id):
|
||||
email_branding = dao_get_email_branding_by_id(email_branding_id)
|
||||
return jsonify(email_branding=email_branding.serialize())
|
||||
|
||||
|
||||
@email_branding_blueprint.route("", methods=["POST"])
|
||||
@email_branding_blueprint.route('', methods=['POST'])
|
||||
def create_email_branding():
|
||||
data = request.get_json()
|
||||
|
||||
validate(data, post_create_email_branding_schema)
|
||||
|
||||
email_branding = EmailBranding(**data)
|
||||
if "text" not in data.keys():
|
||||
if 'text' not in data.keys():
|
||||
email_branding.text = email_branding.name
|
||||
|
||||
dao_create_email_branding(email_branding)
|
||||
return jsonify(data=email_branding.serialize()), 201
|
||||
|
||||
|
||||
@email_branding_blueprint.route("/<uuid:email_branding_id>", methods=["POST"])
|
||||
@email_branding_blueprint.route('/<uuid:email_branding_id>', methods=['POST'])
|
||||
def update_email_branding(email_branding_id):
|
||||
data = request.get_json()
|
||||
|
||||
validate(data, post_update_email_branding_schema)
|
||||
|
||||
fetched_email_branding = dao_get_email_branding_by_id(email_branding_id)
|
||||
if "text" not in data.keys() and "name" in data.keys():
|
||||
data["text"] = data["name"]
|
||||
if 'text' not in data.keys() and 'name' in data.keys():
|
||||
data['text'] = data['name']
|
||||
dao_update_email_branding(fetched_email_branding, **data)
|
||||
|
||||
return jsonify(data=fetched_email_branding.serialize()), 200
|
||||
|
||||
213
app/enums.py
213
app/enums.py
@@ -1,213 +0,0 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class TemplateType(StrEnum):
|
||||
SMS = "sms"
|
||||
EMAIL = "email"
|
||||
LETTER = "letter"
|
||||
|
||||
|
||||
class NotificationType(StrEnum):
|
||||
SMS = "sms"
|
||||
EMAIL = "email"
|
||||
LETTER = "letter"
|
||||
|
||||
|
||||
class TemplateProcessType(StrEnum):
|
||||
# TODO: Should Template.process_type be changed to use this?
|
||||
NORMAL = "normal"
|
||||
PRIORITY = "priority"
|
||||
|
||||
|
||||
class AuthType(StrEnum):
|
||||
SMS = "sms_auth"
|
||||
EMAIL = "email_auth"
|
||||
WEBAUTHN = "webauthn_auth"
|
||||
|
||||
|
||||
class CallbackType(StrEnum):
|
||||
DELIVERY_STATUS = "delivery_status"
|
||||
COMPLAINT = "complaint"
|
||||
|
||||
|
||||
class OrganizationType(StrEnum):
|
||||
FEDERAL = "federal"
|
||||
STATE = "state"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class NotificationStatus(StrEnum):
|
||||
CANCELLED = "cancelled"
|
||||
CREATED = "created"
|
||||
SENDING = "sending"
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
TECHNICAL_FAILURE = "technical-failure"
|
||||
TEMPORARY_FAILURE = "temporary-failure"
|
||||
PERMANENT_FAILURE = "permanent-failure"
|
||||
PENDING_VIRUS_CHECK = "pending-virus-check"
|
||||
VALIDATION_FAILED = "validation-failed"
|
||||
VIRUS_SCAN_FAILED = "virus-scan-failed"
|
||||
|
||||
@classmethod
|
||||
def failed_types(cls) -> tuple[str, ...]:
|
||||
return (
|
||||
cls.TECHNICAL_FAILURE,
|
||||
cls.TEMPORARY_FAILURE,
|
||||
cls.PERMANENT_FAILURE,
|
||||
cls.VALIDATION_FAILED,
|
||||
cls.VIRUS_SCAN_FAILED,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def completed_types(cls) -> tuple[str, ...]:
|
||||
return (
|
||||
cls.SENT,
|
||||
cls.DELIVERED,
|
||||
cls.FAILED,
|
||||
cls.TECHNICAL_FAILURE,
|
||||
cls.TEMPORARY_FAILURE,
|
||||
cls.PERMANENT_FAILURE,
|
||||
cls.CANCELLED,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def success_types(cls) -> tuple[str, ...]:
|
||||
return (cls.SENT, cls.DELIVERED)
|
||||
|
||||
@classmethod
|
||||
def billable_types(cls) -> tuple[str, ...]:
|
||||
return (
|
||||
cls.SENDING,
|
||||
cls.SENT,
|
||||
cls.DELIVERED,
|
||||
cls.PENDING,
|
||||
cls.FAILED,
|
||||
cls.TEMPORARY_FAILURE,
|
||||
cls.PERMANENT_FAILURE,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def billable_sms_types(cls) -> tuple[str, ...]:
|
||||
return (
|
||||
cls.SENDING,
|
||||
cls.SENT, # internationally
|
||||
cls.DELIVERED,
|
||||
cls.PENDING,
|
||||
cls.TEMPORARY_FAILURE,
|
||||
cls.PERMANENT_FAILURE,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sent_email_types(cls) -> tuple[str, ...]:
|
||||
return (
|
||||
cls.SENDING,
|
||||
cls.DELIVERED,
|
||||
cls.TEMPORARY_FAILURE,
|
||||
cls.PERMANENT_FAILURE,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def non_billable_types(cls) -> tuple[str, ...]:
|
||||
return tuple(set(cls) - set(cls.billable_types()))
|
||||
|
||||
|
||||
class PermissionType(StrEnum):
|
||||
MANAGE_USERS = "manage_users"
|
||||
MANAGE_TEMPLATES = "manage_templates"
|
||||
MANAGE_SETTINGS = "manage_settings"
|
||||
SEND_TEXTS = "send_texts"
|
||||
SEND_EMAILS = "send_emails"
|
||||
MANAGE_API_KEYS = "manage_api_keys"
|
||||
PLATFORM_ADMIN = "platform_admin"
|
||||
VIEW_ACTIVITY = "view_activity"
|
||||
|
||||
@classmethod
|
||||
def defaults(cls) -> tuple[str, ...]:
|
||||
return (
|
||||
cls.MANAGE_USERS,
|
||||
cls.MANAGE_TEMPLATES,
|
||||
cls.MANAGE_SETTINGS,
|
||||
cls.SEND_TEXTS,
|
||||
cls.SEND_EMAILS,
|
||||
cls.MANAGE_API_KEYS,
|
||||
cls.VIEW_ACTIVITY,
|
||||
)
|
||||
|
||||
|
||||
class ServicePermissionType(StrEnum):
|
||||
EMAIL = "email"
|
||||
SMS = "sms"
|
||||
INTERNATIONAL_SMS = "international_sms"
|
||||
INBOUND_SMS = "inbound_sms"
|
||||
SCHEDULE_NOTIFICATIONS = "schedule_notifications"
|
||||
EMAIL_AUTH = "email_auth"
|
||||
UPLOAD_DOCUMENT = "upload_document"
|
||||
EDIT_FOLDER_PERMISSIONS = "edit_folder_permissions"
|
||||
|
||||
@classmethod
|
||||
def defaults(cls) -> tuple[str, ...]:
|
||||
return (
|
||||
cls.SMS,
|
||||
cls.EMAIL,
|
||||
cls.INTERNATIONAL_SMS,
|
||||
)
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
MOBILE = "mobile"
|
||||
EMAIL = "email"
|
||||
|
||||
|
||||
class KeyType(StrEnum):
|
||||
NORMAL = "normal"
|
||||
TEAM = "team"
|
||||
TEST = "test"
|
||||
|
||||
|
||||
class JobStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in progress"
|
||||
FINISHED = "finished"
|
||||
SENDING_LIMITS_EXCEEDED = "sending limits exceeded"
|
||||
SCHEDULED = "scheduled"
|
||||
CANCELLED = "cancelled"
|
||||
READY_TO_SEND = "ready to send"
|
||||
SENT_TO_DVLA = "sent to dvla"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class InvitedUserStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
ACCEPTED = "accepted"
|
||||
CANCELLED = "cancelled"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class BrandType(StrEnum):
|
||||
ORG = "org"
|
||||
BOTH = "both"
|
||||
ORG_BANNER = "org_banner"
|
||||
|
||||
|
||||
class CodeType(StrEnum):
|
||||
EMAIL = "email"
|
||||
SMS = "sms"
|
||||
|
||||
|
||||
class AgreementType(StrEnum):
|
||||
MOU = "MOU"
|
||||
IAA = "IAA"
|
||||
|
||||
|
||||
class AgreementStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class StatisticsType(StrEnum):
|
||||
REQUESTED = "requested"
|
||||
DELIVERED = "delivered"
|
||||
FAILURE = "failure"
|
||||
@@ -11,6 +11,7 @@ from app.exceptions import ArchiveValidationError
|
||||
|
||||
class VirusScanError(Exception):
|
||||
def __init__(self, message):
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@@ -24,37 +25,41 @@ class InvalidRequest(Exception):
|
||||
self.status_code = status_code
|
||||
|
||||
def to_dict(self):
|
||||
return {"result": "error", "message": self.message}
|
||||
return {'result': 'error', 'message': self.message}
|
||||
|
||||
def to_dict_v2(self):
|
||||
"""
|
||||
'''
|
||||
Version 2 of the public api error response.
|
||||
"""
|
||||
'''
|
||||
return {
|
||||
"status_code": self.status_code,
|
||||
"errors": [{"error": self.__class__.__name__, "message": self.message}],
|
||||
"errors": [
|
||||
{
|
||||
"error": self.__class__.__name__,
|
||||
"message": self.message
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return str(self.to_dict())
|
||||
|
||||
|
||||
# TODO maintainability what is this for? How to unit test it?
|
||||
def register_errors(blueprint):
|
||||
@blueprint.errorhandler(InvalidEmailError)
|
||||
def invalid_format(error):
|
||||
# Please not that InvalidEmailError is re-raised for InvalidEmail or InvalidPhone,
|
||||
# work should be done in the utils app to tidy up these errors.
|
||||
return jsonify(result="error", message=str(error)), 400
|
||||
return jsonify(result='error', message=str(error)), 400
|
||||
|
||||
@blueprint.errorhandler(AuthError)
|
||||
def authentication_error(error):
|
||||
return jsonify(result="error", message=error.message), error.code
|
||||
return jsonify(result='error', message=error.message), error.code
|
||||
|
||||
@blueprint.errorhandler(ValidationError)
|
||||
def marshmallow_validation_error(error):
|
||||
current_app.logger.info(error)
|
||||
return jsonify(result="error", message=error.messages), 400
|
||||
return jsonify(result='error', message=error.messages), 400
|
||||
|
||||
@blueprint.errorhandler(JsonSchemaValidationError)
|
||||
def jsonschema_validation_error(error):
|
||||
@@ -64,7 +69,7 @@ def register_errors(blueprint):
|
||||
@blueprint.errorhandler(ArchiveValidationError)
|
||||
def archive_validation_error(error):
|
||||
current_app.logger.info(error)
|
||||
return jsonify(result="error", message=str(error)), 400
|
||||
return jsonify(result='error', message=str(error)), 400
|
||||
|
||||
@blueprint.errorhandler(InvalidRequest)
|
||||
def invalid_data(error):
|
||||
@@ -77,32 +82,28 @@ def register_errors(blueprint):
|
||||
def bad_request(e):
|
||||
msg = e.description or "Invalid request parameters"
|
||||
current_app.logger.exception(msg)
|
||||
return jsonify(result="error", message=str(msg)), 400
|
||||
return jsonify(result='error', message=str(msg)), 400
|
||||
|
||||
@blueprint.errorhandler(401)
|
||||
def unauthorized(e):
|
||||
error_message = "Unauthorized: authentication token must be provided"
|
||||
return (
|
||||
jsonify(result="error", message=error_message),
|
||||
401,
|
||||
[("WWW-Authenticate", "Bearer")],
|
||||
)
|
||||
return jsonify(result='error', message=error_message), 401, [('WWW-Authenticate', 'Bearer')]
|
||||
|
||||
@blueprint.errorhandler(403)
|
||||
def forbidden(e):
|
||||
error_message = "Forbidden: invalid authentication token provided"
|
||||
return jsonify(result="error", message=error_message), 403
|
||||
return jsonify(result='error', message=error_message), 403
|
||||
|
||||
@blueprint.errorhandler(429)
|
||||
def limit_exceeded(e):
|
||||
current_app.logger.exception(e)
|
||||
return jsonify(result="error", message=str(e.description)), 429
|
||||
return jsonify(result='error', message=str(e.description)), 429
|
||||
|
||||
@blueprint.errorhandler(NoResultFound)
|
||||
@blueprint.errorhandler(DataError)
|
||||
def no_result_found(e):
|
||||
current_app.logger.info(e)
|
||||
return jsonify(result="error", message="No result found"), 404
|
||||
return jsonify(result='error', message="No result found"), 404
|
||||
|
||||
# this must be defined after all other error handlers since it catches the generic Exception object
|
||||
@blueprint.app_errorhandler(500)
|
||||
@@ -110,6 +111,6 @@ def register_errors(blueprint):
|
||||
def internal_server_error(e):
|
||||
# if e is a werkzeug InternalServerError then it may wrap the original exception. For more details see:
|
||||
# https://flask.palletsprojects.com/en/1.1.x/errorhandling/?highlight=internalservererror#unhandled-exceptions
|
||||
e = getattr(e, "original_exception", e)
|
||||
e = getattr(e, 'original_exception', e)
|
||||
current_app.logger.exception(e)
|
||||
return jsonify(result="error", message="Internal server error"), 500
|
||||
return jsonify(result='error', message="Internal server error"), 500
|
||||
|
||||
@@ -4,11 +4,11 @@ from app.dao.events_dao import dao_create_event
|
||||
from app.errors import register_errors
|
||||
from app.schemas import event_schema
|
||||
|
||||
events = Blueprint("events", __name__, url_prefix="/events")
|
||||
events = Blueprint('events', __name__, url_prefix='/events')
|
||||
register_errors(events)
|
||||
|
||||
|
||||
@events.route("", methods=["POST"])
|
||||
@events.route('', methods=['POST'])
|
||||
def create_event():
|
||||
data = request.get_json()
|
||||
event = event_schema.load(data)
|
||||
|
||||
@@ -2,7 +2,7 @@ from flask_bcrypt import check_password_hash, generate_password_hash
|
||||
|
||||
|
||||
def hashpw(password):
|
||||
return generate_password_hash(password.encode("UTF-8"), 10).decode("utf-8")
|
||||
return generate_password_hash(password.encode('UTF-8'), 10).decode('utf-8')
|
||||
|
||||
|
||||
def check_hash(password, hashed_password):
|
||||
|
||||
@@ -14,7 +14,6 @@ Lastly when to create a version is done manually in dao_utils version decorator
|
||||
session events.
|
||||
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, Table, util
|
||||
@@ -44,13 +43,27 @@ def _history_mapper(local_mapper): # noqa (C901 too complex)
|
||||
getattr(local_mapper.class_, prop.key).impl.active_history = True
|
||||
|
||||
super_mapper = local_mapper.inherits
|
||||
super_history_mapper = getattr(cls, "__history_mapper__", None)
|
||||
super_history_mapper = getattr(cls, '__history_mapper__', None)
|
||||
|
||||
polymorphic_on = None
|
||||
super_fks = []
|
||||
|
||||
def _col_copy(col):
|
||||
orig = col
|
||||
col = col.copy()
|
||||
orig.info['history_copy'] = col
|
||||
col.unique = False
|
||||
|
||||
# if the column is nullable, we could end up overwriting an on-purpose null value with a default.
|
||||
# if it's not nullable, however, the default may be relied upon to correctly set values within the database,
|
||||
# so we should preserve it
|
||||
if col.nullable:
|
||||
col.default = col.server_default = None
|
||||
return col
|
||||
|
||||
properties = util.OrderedDict()
|
||||
if not super_mapper or local_mapper.local_table is not super_mapper.local_table:
|
||||
if not super_mapper or \
|
||||
local_mapper.local_table is not super_mapper.local_table:
|
||||
cols = []
|
||||
version_meta = {"version_meta": True}
|
||||
for column in local_mapper.local_table.c:
|
||||
@@ -58,9 +71,15 @@ def _history_mapper(local_mapper): # noqa (C901 too complex)
|
||||
continue
|
||||
|
||||
col = _col_copy(column)
|
||||
_add_primary_keys_to_super_fks(
|
||||
super_mapper, column, super_fks, super_history_mapper, col
|
||||
)
|
||||
|
||||
if super_mapper and \
|
||||
col_references_table(column, super_mapper.local_table):
|
||||
super_fks.append(
|
||||
(
|
||||
col.key,
|
||||
list(super_history_mapper.local_table.primary_key)[0]
|
||||
)
|
||||
)
|
||||
|
||||
cols.append(col)
|
||||
|
||||
@@ -69,39 +88,54 @@ def _history_mapper(local_mapper): # noqa (C901 too complex)
|
||||
|
||||
orig_prop = local_mapper.get_property_by_column(column)
|
||||
# carry over column re-mappings
|
||||
if len(orig_prop.columns) > 1 or orig_prop.columns[0].key != orig_prop.key:
|
||||
if len(orig_prop.columns) > 1 or \
|
||||
orig_prop.columns[0].key != orig_prop.key:
|
||||
properties[orig_prop.key] = tuple(
|
||||
col.info["history_copy"] for col in orig_prop.columns
|
||||
)
|
||||
col.info['history_copy'] for col in orig_prop.columns)
|
||||
|
||||
_add_version_to_super_fks(super_fks, super_mapper, super_history_mapper)
|
||||
if super_mapper:
|
||||
super_fks.append(
|
||||
(
|
||||
'version', super_history_mapper.local_table.c.version
|
||||
)
|
||||
)
|
||||
|
||||
# "version" stores the integer version id. This column is
|
||||
# required.
|
||||
cols.append(
|
||||
Column(
|
||||
"version",
|
||||
Integer,
|
||||
primary_key=True,
|
||||
autoincrement=False,
|
||||
info=version_meta,
|
||||
)
|
||||
)
|
||||
'version', Integer, primary_key=True,
|
||||
autoincrement=False, info=version_meta))
|
||||
|
||||
_handle_super_fks(super_fks, cols)
|
||||
if super_fks:
|
||||
cols.append(ForeignKeyConstraint(*zip(*super_fks)))
|
||||
|
||||
table = Table(
|
||||
local_mapper.local_table.name + "_history",
|
||||
local_mapper.local_table.name + '_history',
|
||||
local_mapper.local_table.metadata,
|
||||
*cols,
|
||||
schema=local_mapper.local_table.schema
|
||||
)
|
||||
else:
|
||||
table = _handle_single_table_inheritance(local_mapper, super_history_mapper)
|
||||
# single table inheritance. take any additional columns that may have
|
||||
# been added and add them to the history table.
|
||||
for column in local_mapper.local_table.c:
|
||||
if column.key not in super_history_mapper.local_table.c:
|
||||
col = _col_copy(column)
|
||||
super_history_mapper.local_table.append_column(col)
|
||||
table = None
|
||||
|
||||
bases = _get_bases_for_versioned_class(
|
||||
super_history_mapper, table, properties, local_mapper
|
||||
)
|
||||
if super_history_mapper:
|
||||
bases = (super_history_mapper.class_,)
|
||||
|
||||
if table is not None:
|
||||
properties['changed'] = (
|
||||
(table.c.changed, ) +
|
||||
tuple(super_history_mapper.attrs.changed.columns)
|
||||
)
|
||||
|
||||
else:
|
||||
bases = local_mapper.base_mapper.class_.__bases__
|
||||
versioned_cls = type.__new__(type, "%sHistory" % cls.__name__, bases, {})
|
||||
|
||||
m = mapper(
|
||||
@@ -110,77 +144,16 @@ def _history_mapper(local_mapper): # noqa (C901 too complex)
|
||||
inherits=super_history_mapper,
|
||||
polymorphic_on=polymorphic_on,
|
||||
polymorphic_identity=local_mapper.polymorphic_identity,
|
||||
properties=properties,
|
||||
properties=properties
|
||||
)
|
||||
cls.__history_mapper__ = m
|
||||
_add_version_for_non_super_history_mapper(super_history_mapper, local_mapper)
|
||||
|
||||
|
||||
def _add_primary_keys_to_super_fks(
|
||||
super_mapper, column, super_fks, super_history_mapper, col
|
||||
):
|
||||
if super_mapper and col_references_table(column, super_mapper.local_table):
|
||||
super_fks.append(
|
||||
(col.key, list(super_history_mapper.local_table.primary_key)[0])
|
||||
)
|
||||
|
||||
|
||||
def _add_version_to_super_fks(super_fks, super_mapper, super_history_mapper):
|
||||
if super_mapper:
|
||||
super_fks.append(("version", super_history_mapper.local_table.c.version))
|
||||
|
||||
|
||||
def _handle_super_fks(super_fks, cols):
|
||||
if super_fks:
|
||||
cols.append(ForeignKeyConstraint(*zip(*super_fks)))
|
||||
|
||||
|
||||
def _handle_single_table_inheritance(local_mapper, super_history_mapper):
|
||||
# single table inheritance. take any additional columns that may have
|
||||
# been added and add them to the history table.
|
||||
for column in local_mapper.local_table.c:
|
||||
if column.key not in super_history_mapper.local_table.c:
|
||||
col = _col_copy(column)
|
||||
super_history_mapper.local_table.append_column(col)
|
||||
return None
|
||||
|
||||
|
||||
def _get_bases_for_versioned_class(
|
||||
super_history_mapper, table, properties, local_mapper
|
||||
):
|
||||
if super_history_mapper:
|
||||
bases = (super_history_mapper.class_,)
|
||||
|
||||
if table is not None:
|
||||
properties["changed"] = (table.c.changed,) + tuple(
|
||||
super_history_mapper.attrs.changed.columns
|
||||
)
|
||||
|
||||
else:
|
||||
bases = local_mapper.base_mapper.class_.__bases__
|
||||
return bases
|
||||
|
||||
|
||||
def _add_version_for_non_super_history_mapper(super_history_mapper, local_mapper):
|
||||
if not super_history_mapper:
|
||||
local_mapper.local_table.append_column(
|
||||
Column("version", Integer, default=1, nullable=False)
|
||||
Column('version', Integer, default=1, nullable=False)
|
||||
)
|
||||
local_mapper.add_property("version", local_mapper.local_table.c.version)
|
||||
|
||||
|
||||
def _col_copy(col):
|
||||
orig = col
|
||||
col = col.copy()
|
||||
orig.info["history_copy"] = col
|
||||
col.unique = False
|
||||
|
||||
# if the column is nullable, we could end up overwriting an on-purpose null value with a default.
|
||||
# if it's not nullable, however, the default may be relied upon to correctly set values within the database,
|
||||
# so we should preserve it
|
||||
if col.nullable:
|
||||
col.default = col.server_default = None
|
||||
return col
|
||||
local_mapper.add_property(
|
||||
"version", local_mapper.local_table.c.version)
|
||||
|
||||
|
||||
class Versioned(object):
|
||||
@@ -190,7 +163,6 @@ class Versioned(object):
|
||||
mp = mapper(cls, *arg, **kw)
|
||||
_history_mapper(mp)
|
||||
return mp
|
||||
|
||||
return map
|
||||
|
||||
@classmethod
|
||||
@@ -209,6 +181,7 @@ def create_history(obj, history_cls=None):
|
||||
obj_state = attributes.instance_state(obj)
|
||||
data = {}
|
||||
for prop in obj_mapper.iterate_properties:
|
||||
|
||||
# expired object attributes and also deferred cols might not
|
||||
# be in the dict. force it them load no matter what by using getattr().
|
||||
if prop.key not in obj_state.dict:
|
||||
@@ -227,10 +200,10 @@ def create_history(obj, history_cls=None):
|
||||
# not yet have a value before insert
|
||||
|
||||
elif isinstance(prop, RelationshipProperty):
|
||||
if hasattr(history_cls, prop.key + "_id"):
|
||||
if hasattr(history_cls, prop.key + '_id'):
|
||||
foreign_obj = getattr(obj, prop.key)
|
||||
# if it's a nullable relationship, foreign_obj will be None, and we actually want to record that
|
||||
data[prop.key + "_id"] = getattr(foreign_obj, "id", None)
|
||||
data[prop.key + '_id'] = getattr(foreign_obj, 'id', None)
|
||||
|
||||
if not obj.version:
|
||||
obj.version = 1
|
||||
@@ -239,9 +212,9 @@ def create_history(obj, history_cls=None):
|
||||
obj.version += 1
|
||||
now = datetime.datetime.utcnow()
|
||||
obj.updated_at = now
|
||||
data["updated_at"] = now
|
||||
data['updated_at'] = now
|
||||
|
||||
data["version"] = obj.version
|
||||
data["created_at"] = obj.created_at
|
||||
data['version'] = obj.version
|
||||
data['created_at'] = obj.created_at
|
||||
|
||||
return history_cls(**data)
|
||||
|
||||
@@ -8,33 +8,31 @@ from app.dao.inbound_numbers_dao import (
|
||||
)
|
||||
from app.errors import register_errors
|
||||
|
||||
inbound_number_blueprint = Blueprint(
|
||||
"inbound_number", __name__, url_prefix="/inbound-number"
|
||||
)
|
||||
inbound_number_blueprint = Blueprint('inbound_number', __name__, url_prefix='/inbound-number')
|
||||
register_errors(inbound_number_blueprint)
|
||||
|
||||
|
||||
@inbound_number_blueprint.route("", methods=["GET"])
|
||||
@inbound_number_blueprint.route('', methods=['GET'])
|
||||
def get_inbound_numbers():
|
||||
inbound_numbers = [i.serialize() for i in dao_get_inbound_numbers()]
|
||||
|
||||
return jsonify(data=inbound_numbers if inbound_numbers else [])
|
||||
|
||||
|
||||
@inbound_number_blueprint.route("/service/<uuid:service_id>", methods=["GET"])
|
||||
@inbound_number_blueprint.route('/service/<uuid:service_id>', methods=['GET'])
|
||||
def get_inbound_number_for_service(service_id):
|
||||
inbound_number = dao_get_inbound_number_for_service(service_id)
|
||||
|
||||
return jsonify(data=inbound_number.serialize() if inbound_number else {})
|
||||
|
||||
|
||||
@inbound_number_blueprint.route("/service/<uuid:service_id>/off", methods=["POST"])
|
||||
@inbound_number_blueprint.route('/service/<uuid:service_id>/off', methods=['POST'])
|
||||
def post_set_inbound_number_off(service_id):
|
||||
dao_set_inbound_number_active_flag(service_id, active=False)
|
||||
return jsonify(), 204
|
||||
|
||||
|
||||
@inbound_number_blueprint.route("/available", methods=["GET"])
|
||||
@inbound_number_blueprint.route('/available', methods=['GET'])
|
||||
def get_available_inbound_numbers():
|
||||
inbound_numbers = [i.serialize() for i in dao_get_available_inbound_numbers()]
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ get_inbound_sms_for_service_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone_number": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,63 +9,55 @@ from app.dao.inbound_sms_dao import (
|
||||
from app.dao.service_data_retention_dao import (
|
||||
fetch_service_data_retention_by_notification_type,
|
||||
)
|
||||
from app.enums import NotificationType
|
||||
from app.errors import register_errors
|
||||
from app.inbound_sms.inbound_sms_schemas import get_inbound_sms_for_service_schema
|
||||
from app.inbound_sms.inbound_sms_schemas import (
|
||||
get_inbound_sms_for_service_schema,
|
||||
)
|
||||
from app.schema_validation import validate
|
||||
|
||||
inbound_sms = Blueprint(
|
||||
"inbound_sms", __name__, url_prefix="/service/<uuid:service_id>/inbound-sms"
|
||||
'inbound_sms',
|
||||
__name__,
|
||||
url_prefix='/service/<uuid:service_id>/inbound-sms'
|
||||
)
|
||||
|
||||
register_errors(inbound_sms)
|
||||
|
||||
|
||||
@inbound_sms.route("", methods=["POST"])
|
||||
@inbound_sms.route('', methods=['POST'])
|
||||
def post_inbound_sms_for_service(service_id):
|
||||
form = validate(request.get_json(), get_inbound_sms_for_service_schema)
|
||||
user_number = form.get("phone_number")
|
||||
user_number = form.get('phone_number')
|
||||
|
||||
# TODO update this for US formatting
|
||||
# if user_number:
|
||||
# # we use this to normalise to an international phone number - but this may fail if it's an alphanumeric
|
||||
# user_number = try_validate_and_format_phone_number(user_number, international=True)
|
||||
|
||||
inbound_data_retention = fetch_service_data_retention_by_notification_type(
|
||||
service_id, NotificationType.SMS
|
||||
)
|
||||
limit_days = (
|
||||
inbound_data_retention.days_of_retention if inbound_data_retention else 7
|
||||
)
|
||||
inbound_data_retention = fetch_service_data_retention_by_notification_type(service_id, 'sms')
|
||||
limit_days = inbound_data_retention.days_of_retention if inbound_data_retention else 7
|
||||
|
||||
results = dao_get_inbound_sms_for_service(
|
||||
service_id, user_number=user_number, limit_days=limit_days
|
||||
)
|
||||
results = dao_get_inbound_sms_for_service(service_id, user_number=user_number, limit_days=limit_days)
|
||||
return jsonify(data=[row.serialize() for row in results])
|
||||
|
||||
|
||||
@inbound_sms.route("/most-recent", methods=["GET"])
|
||||
@inbound_sms.route('/most-recent', methods=['GET'])
|
||||
def get_most_recent_inbound_sms_for_service(service_id):
|
||||
# used on the service inbox page
|
||||
page = request.args.get("page", 1)
|
||||
page = request.args.get('page', 1)
|
||||
|
||||
inbound_data_retention = fetch_service_data_retention_by_notification_type(
|
||||
service_id, NotificationType.SMS
|
||||
)
|
||||
limit_days = (
|
||||
inbound_data_retention.days_of_retention if inbound_data_retention else 7
|
||||
)
|
||||
inbound_data_retention = fetch_service_data_retention_by_notification_type(service_id, 'sms')
|
||||
limit_days = inbound_data_retention.days_of_retention if inbound_data_retention else 7
|
||||
|
||||
# get most recent message for each user for service
|
||||
results = dao_get_paginated_most_recent_inbound_sms_by_user_number_for_service(
|
||||
service_id, int(page), limit_days
|
||||
)
|
||||
results = dao_get_paginated_most_recent_inbound_sms_by_user_number_for_service(service_id, int(page), limit_days)
|
||||
return jsonify(
|
||||
data=[row.serialize() for row in results.items], has_next=results.has_next
|
||||
data=[row.serialize() for row in results.items],
|
||||
has_next=results.has_next
|
||||
)
|
||||
|
||||
|
||||
@inbound_sms.route("/summary")
|
||||
@inbound_sms.route('/summary')
|
||||
def get_inbound_sms_summary_for_service(service_id):
|
||||
# this is for the dashboard, so always limit to 7 days, even if they have a longer data retention
|
||||
count = dao_count_inbound_sms_for_service(service_id, limit_days=7)
|
||||
@@ -73,11 +65,11 @@ def get_inbound_sms_summary_for_service(service_id):
|
||||
|
||||
return jsonify(
|
||||
count=count,
|
||||
most_recent=most_recent[0].created_at.isoformat() if most_recent else None,
|
||||
most_recent=most_recent[0].created_at.isoformat() if most_recent else None
|
||||
)
|
||||
|
||||
|
||||
@inbound_sms.route("/<uuid:inbound_sms_id>", methods=["GET"])
|
||||
@inbound_sms.route('/<uuid:inbound_sms_id>', methods=['GET'])
|
||||
def get_inbound_by_id(service_id, inbound_sms_id):
|
||||
message = dao_get_inbound_sms_by_id(service_id, inbound_sms_id)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user