mirror of
https://github.com/GSA/notifications-api.git
synced 2026-08-21 06:49:26 -04:00
Compare commits
1 Commits
add-second
...
remove-san
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b482487f |
109
.cfignore
109
.cfignore
@@ -1,109 +0,0 @@
|
||||
# from deploy-exclude.lst
|
||||
|
||||
*__pycache__*
|
||||
.git/*
|
||||
app/assets/*
|
||||
bower_components/*
|
||||
cache/*
|
||||
.cache/*
|
||||
node_modules/*
|
||||
target/*
|
||||
venv/*
|
||||
build/*
|
||||
.envrc
|
||||
tests/.cache/*
|
||||
.cf/*
|
||||
|
||||
# from .gitignore
|
||||
|
||||
queues.csv
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
venv-freeze/
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
/cache
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
.pytest_cache
|
||||
coverage.xml
|
||||
test_results.xml
|
||||
*,cover
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
.idea/
|
||||
.vscode
|
||||
|
||||
# Mac
|
||||
*.DS_Store
|
||||
environment.sh
|
||||
.envrc
|
||||
.env
|
||||
.env*
|
||||
varsfile
|
||||
|
||||
celerybeat-schedule
|
||||
|
||||
# CloudFoundry
|
||||
.cf
|
||||
varsfile*
|
||||
.secret*
|
||||
|
||||
/scripts/run_my_tests.sh
|
||||
|
||||
# Terraform
|
||||
.terraform.lock.hcl
|
||||
**/.terraform/*
|
||||
secrets.auto.tfvars
|
||||
terraform.tfstate
|
||||
terraform.tfstate.backup
|
||||
388
.ds.baseline
388
.ds.baseline
@@ -1,388 +0,0 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{
|
||||
"name": "ArtifactoryDetector"
|
||||
},
|
||||
{
|
||||
"name": "AWSKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "AzureStorageKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "Base64HighEntropyString",
|
||||
"limit": 4.5
|
||||
},
|
||||
{
|
||||
"name": "BasicAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "CloudantDetector"
|
||||
},
|
||||
{
|
||||
"name": "DiscordBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitHubTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitLabTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "HexHighEntropyString",
|
||||
"limit": 3.0
|
||||
},
|
||||
{
|
||||
"name": "IbmCloudIamDetector"
|
||||
},
|
||||
{
|
||||
"name": "IbmCosHmacDetector"
|
||||
},
|
||||
{
|
||||
"name": "IPPublicDetector"
|
||||
},
|
||||
{
|
||||
"name": "JwtTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "KeywordDetector",
|
||||
"keyword_exclude": ""
|
||||
},
|
||||
{
|
||||
"name": "MailchimpDetector"
|
||||
},
|
||||
{
|
||||
"name": "NpmDetector"
|
||||
},
|
||||
{
|
||||
"name": "OpenAIDetector"
|
||||
},
|
||||
{
|
||||
"name": "PrivateKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "PypiTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "SendGridDetector"
|
||||
},
|
||||
{
|
||||
"name": "SlackDetector"
|
||||
},
|
||||
{
|
||||
"name": "SoftlayerDetector"
|
||||
},
|
||||
{
|
||||
"name": "SquareOAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "StripeDetector"
|
||||
},
|
||||
{
|
||||
"name": "TelegramBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "TwilioKeyDetector"
|
||||
}
|
||||
],
|
||||
"filters_used": [
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||
"filename": ".ds.baseline"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
".github/workflows/checks.yml": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": ".github/workflows/checks.yml",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 28,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": ".github/workflows/checks.yml",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 45,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
".github/workflows/daily_checks.yml": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": ".github/workflows/daily_checks.yml",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 61,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": ".github/workflows/daily_checks.yml",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 77,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"app/enums.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "app/enums.py",
|
||||
"hashed_secret": "12322e07b94ee3c7cd65a2952ece441538b53eb3",
|
||||
"is_verified": false,
|
||||
"line_number": 123,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"app/notifications/receive_notifications.py": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "app/notifications/receive_notifications.py",
|
||||
"hashed_secret": "d70eab08607a4d05faa2d0d6647206599e9abc65",
|
||||
"is_verified": false,
|
||||
"line_number": 29,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"deploy-config/sandbox.yml": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "deploy-config/sandbox.yml",
|
||||
"hashed_secret": "113151dd10316fcb0d5507b6215d78e2f3fe9e54",
|
||||
"is_verified": false,
|
||||
"line_number": 11,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"sample.env": [
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "sample.env",
|
||||
"hashed_secret": "5b98cf4c3d794c8af1fcd7991e89cd4e52fb42a4",
|
||||
"is_verified": false,
|
||||
"line_number": 16,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/aws/test_s3.py": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "tests/app/aws/test_s3.py",
|
||||
"hashed_secret": "67a74306b06d0c01624fe0d0249a570f4d093747",
|
||||
"is_verified": false,
|
||||
"line_number": 42,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/clients/test_document_download.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/clients/test_document_download.py",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 14,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/clients/test_performance_platform.py": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "tests/app/clients/test_performance_platform.py",
|
||||
"hashed_secret": "76bb66c38ac4046bf73cd4a2c35a2b0af94aeb61",
|
||||
"is_verified": false,
|
||||
"line_number": 84,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/dao/test_services_dao.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/dao/test_services_dao.py",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 289,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/dao/test_users_dao.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/dao/test_users_dao.py",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 69,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/dao/test_users_dao.py",
|
||||
"hashed_secret": "f2c57870308dc87f432e5912d4de6f8e322721ba",
|
||||
"is_verified": false,
|
||||
"line_number": 199,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/db.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/db.py",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 90,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/notifications/test_receive_notification.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/notifications/test_receive_notification.py",
|
||||
"hashed_secret": "913a73b565c8e2c8ed94497580f619397709b8b6",
|
||||
"is_verified": false,
|
||||
"line_number": 27,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "tests/app/notifications/test_receive_notification.py",
|
||||
"hashed_secret": "d70eab08607a4d05faa2d0d6647206599e9abc65",
|
||||
"is_verified": false,
|
||||
"line_number": 57,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/notifications/test_validators.py": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "tests/app/notifications/test_validators.py",
|
||||
"hashed_secret": "6c1a8443963d02d13ffe575a71abe19ea731fb66",
|
||||
"is_verified": false,
|
||||
"line_number": 672,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/service/test_rest.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/service/test_rest.py",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 1285,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/test_cloudfoundry_config.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/test_cloudfoundry_config.py",
|
||||
"hashed_secret": "e5e178db7317356946d13e5d2da037d39ac61c71",
|
||||
"is_verified": false,
|
||||
"line_number": 12,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "tests/app/test_cloudfoundry_config.py",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 14,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/test_cloudfoundry_config.py",
|
||||
"hashed_secret": "cfd48edeb81ba7d48cbddcf1eeede25ba67057e8",
|
||||
"is_verified": false,
|
||||
"line_number": 33,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/app/user/test_rest.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/user/test_rest.py",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 110,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/app/user/test_rest.py",
|
||||
"hashed_secret": "0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33",
|
||||
"is_verified": false,
|
||||
"line_number": 864,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/notifications_utils/clients/antivirus/test_antivirus_client.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/notifications_utils/clients/antivirus/test_antivirus_client.py",
|
||||
"hashed_secret": "932b25270abe1301c22c709a19082dff07d469ff",
|
||||
"is_verified": false,
|
||||
"line_number": 16,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/notifications_utils/clients/encryption/test_encryption_client.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/notifications_utils/clients/encryption/test_encryption_client.py",
|
||||
"hashed_secret": "f1e923a9667de11be6a210849a8651c1bfd81605",
|
||||
"is_verified": false,
|
||||
"line_number": 13,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/notifications_utils/clients/zendesk/test_zendesk_client.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/notifications_utils/clients/zendesk/test_zendesk_client.py",
|
||||
"hashed_secret": "913a73b565c8e2c8ed94497580f619397709b8b6",
|
||||
"is_verified": false,
|
||||
"line_number": 16,
|
||||
"is_secret": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2025-02-27T21:09:45Z"
|
||||
}
|
||||
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
|
||||
58
.github/actions/deploy-proxy/action.yml
vendored
58
.github/actions/deploy-proxy/action.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Deploy egress proxy
|
||||
description: Set egress space security groups and deploy proxy
|
||||
inputs:
|
||||
cf_org:
|
||||
description: The org the target app exists in.
|
||||
required: true
|
||||
cf_space:
|
||||
description: The space the target app exists in.
|
||||
required: true
|
||||
app:
|
||||
description: application name to be proxied.
|
||||
required: true
|
||||
proxy_repo:
|
||||
description: git repo for cg-egress-proxy
|
||||
default: https://github.com/GSA-TTS/cg-egress-proxy.git
|
||||
proxy_version:
|
||||
description: git ref to be deployed
|
||||
default: main
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install cf-cli
|
||||
shell: bash
|
||||
run: |
|
||||
curl -A "cg-deploy-action" -v -L -o cf-cli_amd64.deb 'https://packages.cloudfoundry.org/stable?release=debian64&version=v8&source=github'
|
||||
sudo dpkg -i cf-cli_amd64.deb
|
||||
- name: Login to cf-cli
|
||||
shell: bash
|
||||
run: |
|
||||
cf api api.fr.cloud.gov
|
||||
cf auth
|
||||
- name: Target org and space
|
||||
shell: bash
|
||||
run: cf target -o ${{ inputs.cf_org }} -s ${{ inputs.cf_space }}
|
||||
- name: Set restricted space egress
|
||||
shell: bash
|
||||
run: ./terraform/set_space_egress.sh -t -s ${{ inputs.cf_space }}
|
||||
- name: Set public space egress
|
||||
shell: bash
|
||||
run: ./terraform/set_space_egress.sh -p -s ${{ inputs.cf_space }}-egress
|
||||
- name: Create temp directory
|
||||
shell: bash
|
||||
id: create-temp-dir
|
||||
run: echo "path=$(mktemp -d -t egress-XXXXXXXXXX --tmpdir=$RUNNER_TEMP)" >> $GITHUB_OUTPUT
|
||||
- name: Clone cg-egress-proxy
|
||||
shell: bash
|
||||
run: git clone ${{ inputs.proxy_repo }} ${{ steps.create-temp-dir.outputs.path }}
|
||||
- name: Switch to deploy ref
|
||||
shell: bash
|
||||
working-directory: ${{ steps.create-temp-dir.outputs.path }}
|
||||
run: git checkout ${{ inputs.proxy_version }}
|
||||
- name: Copy config files
|
||||
shell: bash
|
||||
run: cp ./deploy-config/egress_proxy/${{ inputs.app }}.*.acl ${{ steps.create-temp-dir.outputs.path }}
|
||||
- name: 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
|
||||
18
.github/actions/setup-project/action.yml
vendored
18
.github/actions/setup-project/action.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Set up project
|
||||
description: Setup python & install dependencies
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install container dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update \
|
||||
&& sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev
|
||||
- name: Set up Python 3.12.3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12.3"
|
||||
- name: Install poetry
|
||||
shell: bash
|
||||
run: pip install poetry==1.8.5
|
||||
13
.github/dependabot.yml
vendored
13
.github/dependabot.yml
vendored
@@ -1,13 +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"
|
||||
labels:
|
||||
- "dependabot" # Custom label to identify Dependabot PRs
|
||||
22
.github/pull_request_template.md
vendored
22
.github/pull_request_template.md
vendored
@@ -1,22 +0,0 @@
|
||||
<!--
|
||||
Not sure what you should include or write in a pull request? Please read the
|
||||
[pull request documentation in our docs!](https://github.com/GSA/notifications-api/blob/main/docs/all.md#pull-requests)
|
||||
-->
|
||||
|
||||
*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 detailed description here.
|
||||
|
||||
## TODO (optional)
|
||||
|
||||
* [ ] TODO item 1
|
||||
* [ ] TODO item 2
|
||||
* [ ] TODO item ...
|
||||
|
||||
## Security Considerations
|
||||
|
||||
* Consideration 1
|
||||
* Consideration 2
|
||||
* Consideration ...
|
||||
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@v4
|
||||
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.3
|
||||
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
|
||||
153
.github/workflows/checks.yml
vendored
153
.github/workflows/checks.yml
vendored
@@ -1,153 +0,0 @@
|
||||
name: Run checks
|
||||
|
||||
on: [push]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DEBUG: True
|
||||
NOTIFY_ENVIRONMENT: test
|
||||
NEW_RELIC_CONFIG_FILE: newrelic.ini
|
||||
NEW_RELIC_ENVIRONMENT: test
|
||||
FLASK_APP: application.py
|
||||
WERKZEUG_DEBUG_PIN: off
|
||||
REDIS_ENABLED: 0
|
||||
AWS_US_TOLL_FREE_NUMBER: "+18556438890"
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: test_notification_api
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
# Maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Install application dependencies
|
||||
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 }}
|
||||
NOTIFY_E2E_TEST_EMAIL_TWO: ${{ secrets.NOTIFY_E2E_TEST_EMAIL_TWO }}
|
||||
NOTIFY_E2E_TEST_PASSWORD_TWO: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD_TWO }}
|
||||
|
||||
- name: Check imports alphabetized
|
||||
run: poetry run isort --check-only ./app ./tests
|
||||
- name: Run style checks
|
||||
run: poetry run flake8 .
|
||||
- name: Check for dead code
|
||||
run: make dead-code
|
||||
- name: Run tests with coverage
|
||||
run: poetry run coverage run --omit=*/migrations/*,*/tests/* -m pytest --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 }}
|
||||
NOTIFY_E2E_TEST_EMAIL_TWO: ${{ secrets.NOTIFY_E2E_TEST_EMAIL_TWO }}
|
||||
NOTIFY_E2E_TEST_PASSWORD_TWO: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD_TWO }}
|
||||
- name: Check coverage threshold
|
||||
# TODO get this back up to 95
|
||||
run: poetry run coverage report -m --fail-under=93
|
||||
|
||||
validate-new-relic-config:
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Install poetry packages
|
||||
run: poetry install
|
||||
- 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
|
||||
|
||||
pip-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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.8
|
||||
with:
|
||||
inputs: requirements.txt
|
||||
ignore-vulns: |
|
||||
PYSEC-2022-43162
|
||||
|
||||
static-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Install bandit
|
||||
run: pip install bandit
|
||||
- name: Run scan
|
||||
run: bandit -r app/ --confidence-level medium
|
||||
|
||||
dynamic-scan:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: test_notification_api
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
# Maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Install application dependencies
|
||||
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 }}
|
||||
NOTIFY_E2E_TEST_EMAIL_TWO: ${{ secrets.NOTIFY_E2E_TEST_EMAIL_TWO }}
|
||||
NOTIFY_E2E_TEST_PASSWORD_TWO: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD_TWO }}
|
||||
- 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.9.0
|
||||
with:
|
||||
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
|
||||
target: 'http://localhost:6011/docs/openapi.yml'
|
||||
fail_action: true
|
||||
allow_issue_writing: false
|
||||
rules_file_name: 'zap.conf'
|
||||
cmd_options: '-I'
|
||||
93
.github/workflows/codeql.yml
vendored
93
.github/workflows/codeql.yml
vendored
@@ -1,93 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "production" ]
|
||||
pull_request:
|
||||
branches: [ "main", "production" ]
|
||||
schedule:
|
||||
- cron: '15 8 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: python
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
94
.github/workflows/daily_checks.yml
vendored
94
.github/workflows/daily_checks.yml
vendored
@@ -1,94 +0,0 @@
|
||||
name: Run daily scans
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# cron format: 'minute hour dayofmonth month dayofweek'
|
||||
# this will run at noon UTC every day (7am EST / 8am EDT)
|
||||
- cron: '0 12 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DEBUG: True
|
||||
NOTIFY_ENVIRONMENT: test
|
||||
NEW_RELIC_CONFIG_FILE: newrelic.ini
|
||||
NEW_RELIC_ENVIRONMENT: test
|
||||
FLASK_APP: application.py
|
||||
WERKZEUG_DEBUG_PIN: off
|
||||
REDIS_ENABLED: 0
|
||||
AWS_US_TOLL_FREE_NUMBER: "+18556438890"
|
||||
|
||||
jobs:
|
||||
pip-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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.1.0
|
||||
with:
|
||||
inputs: requirements.txt
|
||||
- name: Upload pip-audit artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pip-audit-report
|
||||
path: /tmp/pip-audit-output.txt
|
||||
|
||||
static-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-project
|
||||
- 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@v4
|
||||
with:
|
||||
name: bandit-report
|
||||
path: /tmp/bandit-output.txt
|
||||
|
||||
dynamic-scan:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: test_notification_api
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
# Maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-project
|
||||
- name: Install application dependencies
|
||||
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.9.0
|
||||
with:
|
||||
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
|
||||
target: 'http://localhost:6011/docs/openapi.yml'
|
||||
fail_action: true
|
||||
allow_issue_writing: false
|
||||
rules_file_name: 'zap.conf'
|
||||
cmd_options: '-I'
|
||||
105
.github/workflows/deploy-demo.yml
vendored
105
.github/workflows/deploy-demo.yml
vendored
@@ -1,105 +0,0 @@
|
||||
name: Deploy to demo environment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ production ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: demo
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform init
|
||||
working-directory: terraform/demo
|
||||
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
|
||||
working-directory: terraform/demo
|
||||
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: cloud-gov/cg-cli-tools@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=NONCE&prompt=select_account&redirect_uri=https://notify-demo.app.cloud.gov/set-up-your-profile&response_type=code&scope=openid+email&state=STATE"
|
||||
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-demo
|
||||
cf_command: >-
|
||||
push -f manifest.yml
|
||||
--vars-file deploy-config/demo.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"
|
||||
--strategy rolling
|
||||
|
||||
- name: Update templates
|
||||
uses: cloud-gov/cg-cli-tools@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=NONCE&prompt=select_account&redirect_uri=https://notify-demo.app.cloud.gov/set-up-your-profile&response_type=code&scope=openid+email&state=STATE"
|
||||
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-demo
|
||||
cf_command: >-
|
||||
run-task notify-api-demo --command "flask command update-templates"
|
||||
|
||||
|
||||
- name: Deploy egress proxy
|
||||
uses: ./.github/actions/deploy-proxy
|
||||
env:
|
||||
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
with:
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-demo
|
||||
app: notify-api-demo
|
||||
108
.github/workflows/deploy-prod.yml
vendored
108
.github/workflows/deploy-prod.yml
vendored
@@ -1,108 +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@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform init
|
||||
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
|
||||
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: cloud-gov/cg-cli-tools@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=NONCE&prompt=select_account&redirect_uri=https://beta.notify.gov/set-up-your-profile&response_type=code&scope=openid+email&state=STATE"
|
||||
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-production
|
||||
cf_command: >-
|
||||
push -f manifest.yml
|
||||
--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"
|
||||
--strategy rolling
|
||||
|
||||
- name: Update templates
|
||||
uses: cloud-gov/cg-cli-tools@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=NONCE&prompt=select_account&redirect_uri=https://beta.notify.gov/set-up-your-profile&response_type=code&scope=openid+email&state=STATE"
|
||||
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-production
|
||||
cf_command: >-
|
||||
run-task notify-api-production --command "flask command update-templates"
|
||||
|
||||
- name: Deploy egress proxy
|
||||
uses: ./.github/actions/deploy-proxy
|
||||
env:
|
||||
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
with:
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-production
|
||||
app: notify-api-production
|
||||
123
.github/workflows/deploy.yml
vendored
123
.github/workflows/deploy.yml
vendored
@@ -1,123 +0,0 @@
|
||||
name: Deploy to staging environment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ Run checks ]
|
||||
types:
|
||||
- completed
|
||||
branches: [ main ] # Redundant, workflow_run events are only triggered on default branch (`main`)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
|
||||
environment: staging
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform init
|
||||
working-directory: terraform/staging
|
||||
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
|
||||
working-directory: terraform/staging
|
||||
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: cloud-gov/cg-cli-tools@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 }}
|
||||
NOTIFY_E2E_TEST_EMAIL_TWO: ${{ secrets.NOTIFY_E2E_TEST_EMAIL_TWO }}
|
||||
NOTIFY_E2E_TEST_PASSWORD_TWO: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD_TWO }}
|
||||
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=NONCE&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/set-up-your-profile&response_type=code&scope=openid+email&state=STATE"
|
||||
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-staging
|
||||
cf_command: >-
|
||||
push -f manifest.yml
|
||||
--vars-file deploy-config/staging.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 NOTIFY_E2E_TEST_EMAIL_TWO: "$NOTIFY_E2E_TEST_EMAIL_TWO"
|
||||
--var NOTIFY_E2E_TEST_PASSWORD_TWO: "$NOTIFY_E2E_TEST_PASSWORD_TWO"
|
||||
--var LOGIN_DOT_GOV_REGISTRATION_URL="$LOGIN_DOT_GOV_REGISTRATION_URL"
|
||||
--strategy rolling
|
||||
|
||||
- name: Update templates
|
||||
uses: cloud-gov/cg-cli-tools@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 }}
|
||||
NOTIFY_E2E_TEST_EMAIL_TWO: ${{ secrets.NOTIFY_E2E_TEST_EMAIL_TWO }}
|
||||
NOTIFY_E2E_TEST_PASSWORD_TWO: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD_TWO }}
|
||||
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=NONCE&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/set-up-your-profile&response_type=code&scope=openid+email&state=STATE"
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-staging
|
||||
cf_command: >-
|
||||
run-task notify-api-staging --command "flask command update-templates"
|
||||
|
||||
- name: Deploy egress proxy
|
||||
uses: ./.github/actions/deploy-proxy
|
||||
env:
|
||||
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
with:
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-staging
|
||||
app: notify-api-staging
|
||||
|
||||
bail:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
script: core.setFailed('Checks failed, not deploying')
|
||||
90
.github/workflows/drift.yml
vendored
90
.github/workflows/drift.yml
vendored
@@ -1,90 +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@v4
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- 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@v4
|
||||
with:
|
||||
ref: 'production'
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- 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@v4
|
||||
with:
|
||||
ref: 'production'
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- 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
|
||||
68
.github/workflows/restage-apps.yml
vendored
68
.github/workflows/restage-apps.yml
vendored
@@ -1,68 +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: Update env variables api
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-${{ inputs.environment }}
|
||||
command: |
|
||||
cf set-env notify-api-${{inputs.environment}} DANGEROUS_SALT "${{secrets.DANGEROUS_SALT}}"
|
||||
cf set-env notify-api-${{inputs.environment}} ADMIN_CLIENT_SECRET "${{secrets.ADMIN_CLIENT_SECRET}}"
|
||||
cf set-env notify-api-${{inputs.environment}} SECRET_KEY "${{secrets.SECRET_KEY}}"
|
||||
cf set-env notify-api-${{inputs.environment}} LOGIN_PEM "${{secrets.LOGIN_PEM}}"
|
||||
cf set-env notify-api-${{inputs.environment}} NOTIFY_E2E_TEST_EMAIL "${{secrets.NOTIFY_E2E_TEST_EMAIL}}"
|
||||
cf set-env notify-api-${{inputs.environment}} NOTIFY_E2E_TEST_PASSWORD "${{secrets.NOTIFY_E2E_TEST_PASSWORD}}"
|
||||
|
||||
- name: Update env variables admin
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-${{ inputs.environment }}
|
||||
command: |
|
||||
cf set-env notify-admin-${{inputs.environment}} DANGEROUS_SALT "${{secrets.DANGEROUS_SALT}}"
|
||||
cf set-env notify-admin-${{inputs.environment}} ADMIN_CLIENT_SECRET "${{secrets.ADMIN_CLIENT_SECRET}}"
|
||||
cf set-env notify-admin-${{inputs.environment}} SECRET_KEY "${{secrets.SECRET_KEY}}"
|
||||
cf set-env notify-admin-${{inputs.environment}} LOGIN_PEM "${{secrets.LOGIN_PEM}}"
|
||||
cf set-env notify-admin-${{inputs.environment}} NOTIFY_E2E_TEST_EMAIL "${{secrets.NOTIFY_E2E_TEST_EMAIL}}"
|
||||
cf set-env notify-admin-${{inputs.environment}} NOTIFY_E2E_TEST_PASSWORD "${{secrets.NOTIFY_E2E_TEST_PASSWORD}}"
|
||||
cf set-env notify-api-${{inputs.environment}} E2E_BUCKET_NAME "${{secrets.E2E_BUCKET_NAME}}"
|
||||
|
||||
- name: Restage ${{matrix.app}}
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-${{ inputs.environment }}
|
||||
command: |
|
||||
cf restage --strategy rolling notify-${{matrix.app}}-${{inputs.environment}}
|
||||
- name: Restage ${{matrix.app}} egress
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
|
||||
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
|
||||
cf_org: gsa-tts-benefits-studio
|
||||
cf_space: notify-${{ inputs.environment }}-egress
|
||||
command: "cf restage --strategy rolling egress-proxy-notify-${{matrix.app}}-${{inputs.environment}}"
|
||||
87
.github/workflows/terraform-demo.yml
vendored
87
.github/workflows/terraform-demo.yml
vendored
@@ -1,87 +0,0 @@
|
||||
name: Run Terraform plan in demo
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ production ]
|
||||
paths: [ 'terraform/**' ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: terraform/demo
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
name: Terraform plan
|
||||
runs-on: ubuntu-latest
|
||||
environment: demo
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform format
|
||||
id: format
|
||||
run: terraform fmt -check
|
||||
|
||||
- name: Terraform init
|
||||
id: init
|
||||
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 validate
|
||||
id: validation
|
||||
run: terraform validate -no-color
|
||||
|
||||
- name: Terraform plan
|
||||
id: plan
|
||||
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 plan -no-color -input=false 2>&1 | tee plan_output.txt
|
||||
|
||||
- name: Read Terraform plan output file
|
||||
id: terraform_output
|
||||
uses: juliangruber/read-file-action@v1
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
path: ./terraform/demo/plan_output.txt
|
||||
|
||||
# inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow
|
||||
- name: Update PR
|
||||
uses: actions/github-script@v7
|
||||
# we would like to update the PR even when a prior step failed
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
script: |
|
||||
const output = `Terraform Format and Style: ${{ steps.format.outcome }}
|
||||
Terraform Initialization: ${{ steps.init.outcome }}
|
||||
Terraform Validation: ${{ steps.validation.outcome }}
|
||||
Terraform Plan: ${{ steps.plan.outcome }}
|
||||
|
||||
<details><summary>Show Plan</summary>
|
||||
|
||||
\`\`\`\n
|
||||
${{ steps.terraform_output.outputs.content }}
|
||||
\`\`\`
|
||||
|
||||
</details>
|
||||
|
||||
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: output
|
||||
})
|
||||
87
.github/workflows/terraform-production.yml
vendored
87
.github/workflows/terraform-production.yml
vendored
@@ -1,87 +0,0 @@
|
||||
name: Run Terraform plan in production
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ production ]
|
||||
paths: [ 'terraform/**' ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: terraform/production
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
name: Terraform plan
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform format
|
||||
id: format
|
||||
run: terraform fmt -check
|
||||
|
||||
- name: Terraform init
|
||||
id: init
|
||||
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 validate
|
||||
id: validation
|
||||
run: terraform validate -no-color
|
||||
|
||||
- name: Terraform plan
|
||||
id: plan
|
||||
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 plan -no-color -input=false 2>&1 | tee plan_output.txt
|
||||
|
||||
- name: Read Terraform plan output file
|
||||
id: terraform_output
|
||||
uses: juliangruber/read-file-action@v1
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
path: ./terraform/production/plan_output.txt
|
||||
|
||||
# inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow
|
||||
- name: Update PR
|
||||
uses: actions/github-script@v7
|
||||
# we would like to update the PR even when a prior step failed
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
script: |
|
||||
const output = `Terraform Format and Style: ${{ steps.format.outcome }}
|
||||
Terraform Initialization: ${{ steps.init.outcome }}
|
||||
Terraform Validation: ${{ steps.validation.outcome }}
|
||||
Terraform Plan: ${{ steps.plan.outcome }}
|
||||
|
||||
<details><summary>Show Plan</summary>
|
||||
|
||||
\`\`\`\n
|
||||
${{ steps.terraform_output.outputs.content }}
|
||||
\`\`\`
|
||||
|
||||
</details>
|
||||
|
||||
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: output
|
||||
})
|
||||
87
.github/workflows/terraform-staging.yml
vendored
87
.github/workflows/terraform-staging.yml
vendored
@@ -1,87 +0,0 @@
|
||||
name: Run Terraform plan in staging
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths: [ 'terraform/**' ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: terraform/staging
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
name: Terraform plan
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Looks like we need to install Terraform ourselves now!
|
||||
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "^1.7.5"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform format
|
||||
id: format
|
||||
run: terraform fmt -check
|
||||
|
||||
- name: Terraform init
|
||||
id: init
|
||||
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 validate
|
||||
id: validation
|
||||
run: terraform validate -no-color
|
||||
|
||||
- name: Terraform plan
|
||||
id: plan
|
||||
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 plan -no-color -input=false 2>&1 | tee plan_output.txt
|
||||
|
||||
- name: Read Terraform plan output file
|
||||
id: terraform_output
|
||||
uses: juliangruber/read-file-action@v1
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
path: ./terraform/staging/plan_output.txt
|
||||
|
||||
# inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow
|
||||
- name: Update PR
|
||||
uses: actions/github-script@v7
|
||||
# we would like to update the PR even when a prior step failed
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
script: |
|
||||
const output = `Terraform Format and Style: ${{ steps.format.outcome }}
|
||||
Terraform Initialization: ${{ steps.init.outcome }}
|
||||
Terraform Validation: ${{ steps.validation.outcome }}
|
||||
Terraform Plan: ${{ steps.plan.outcome }}
|
||||
|
||||
<details><summary>Show Plan</summary>
|
||||
|
||||
\`\`\`\n
|
||||
${{ steps.terraform_output.outputs.content }}
|
||||
\`\`\`
|
||||
|
||||
</details>
|
||||
|
||||
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: output
|
||||
})
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -4,7 +4,6 @@ queues.csv
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
venv-freeze/
|
||||
|
||||
@@ -29,7 +28,6 @@ var/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
/cache
|
||||
requirements.txt
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
@@ -45,7 +43,6 @@ pip-delete-this-directory.txt
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage_cache
|
||||
.coverage.*
|
||||
.cache
|
||||
.pytest_cache
|
||||
@@ -59,7 +56,6 @@ test_results.xml
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
/logs/*
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
@@ -73,27 +69,10 @@ target/
|
||||
*.DS_Store
|
||||
environment.sh
|
||||
.envrc
|
||||
.env
|
||||
.env*
|
||||
varsfile
|
||||
|
||||
celerybeat-schedule
|
||||
celerybeat-schedule.db
|
||||
|
||||
# CloudFoundry
|
||||
.cf
|
||||
varsfile*
|
||||
.secret*
|
||||
|
||||
/scripts/run_my_tests.sh
|
||||
|
||||
# Terraform
|
||||
.terraform.lock.hcl
|
||||
**/.terraform/*
|
||||
secrets.auto.tfvars
|
||||
terraform.tfstate
|
||||
terraform.tfstate.backup
|
||||
|
||||
|
||||
# Pyenv
|
||||
.python-version
|
||||
|
||||
@@ -1,24 +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: v4.6.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
|
||||
- id: check-ast
|
||||
- id: fix-byte-order-marker
|
||||
- id: detect-aws-credentials
|
||||
args: [--allow-missing-credentials]
|
||||
- id: detect-private-key
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args: ['--baseline', '.ds.baseline']
|
||||
9
.profile
9
.profile
@@ -1,9 +0,0 @@
|
||||
##
|
||||
# Cloud Foundry app initialization script
|
||||
# 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"
|
||||
8
.pyup.yml
Normal file
8
.pyup.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
# see https://pyup.io/docs/configuration/ for all available options
|
||||
|
||||
schedule: "every week on wednesday"
|
||||
|
||||
search: False
|
||||
requirements:
|
||||
- requirements.in
|
||||
- requirements_for_test.txt
|
||||
@@ -1,19 +0,0 @@
|
||||
# Welcome!
|
||||
|
||||
We're so glad you're thinking about contributing to a [open source project of the U.S. government](https://code.gov/)! If you're unsure about anything, just ask -- or submit the issue or pull request anyway. The worst that can happen is you'll be politely asked to change something. We love all friendly contributions.
|
||||
|
||||
We encourage you to read this project's CONTRIBUTING policy (you are here), its [LICENSE](LICENSE.md), and its [README](README.md).
|
||||
|
||||
## Policies
|
||||
|
||||
We want to ensure a welcoming environment for all of our projects. Our staff follow the [TTS Code of Conduct](https://18f.gsa.gov/code-of-conduct/) and all contributors should do the same.
|
||||
|
||||
This project is adhering to the [18F Open Source Policy](https://github.com/18f/open-source-policy).
|
||||
|
||||
As part of a U.S. government agency, the General Services Administration (GSA)’s Technology Transformation Services (TTS) takes seriously our responsibility to protect the public’s information, including financial and personal information, from unwarranted disclosure. For more information about security and vulnerability disclosure for our projects, please read our [18F Vulnerability Disclosure Policy](https://18f.gsa.gov/vulnerability-disclosure-policy/).
|
||||
|
||||
## Public domain
|
||||
|
||||
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.
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Government Digital Service
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
53
LICENSE.md
53
LICENSE.md
@@ -1,53 +0,0 @@
|
||||
# License
|
||||
|
||||
## A few parts of this project are not in the public domain
|
||||
|
||||
### Copyright ownership of Works created by U.S. federal employees
|
||||
|
||||
This repository contains both the original work for [GOV.UK Notify](https://github.com/alphagov/notifications-api) (the Software, as defined in the MIT License) and modifications made by the General Services Administration (GSA). This repository combines the Software and these GSA modifications, which is a “joint work” of the Software and modifications combined into a single work.
|
||||
|
||||
As a work of the United States Government, the software modifications made by GSA are not subject to copyright within the United States. Additionally, GSA waives copyright and related rights in its software modifications worldwide through the [CC0 1.0 Universal Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
|
||||
The Software remains subject to copyright under the MIT License. This consists of all work until [commit `e604385`](https://github.com/GSA/notifications-api/commit/e604385e0cf4c2ab8c6451b7120ceb196cce21b5) on June 1, 2022.
|
||||
|
||||
#### Full license text for the MIT licensed files:
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
## The rest of this project is in the worldwide public domain
|
||||
|
||||
As a work of the United States government, this project is in the public domain within the United States.
|
||||
|
||||
Additionally, we waive copyright and related rights in the work worldwide through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
|
||||
### CC0 1.0 Universal Summary
|
||||
|
||||
This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode).
|
||||
|
||||
#### No copyright
|
||||
|
||||
The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
|
||||
|
||||
You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.
|
||||
|
||||
#### Other information
|
||||
|
||||
In no way are the patent or trademark rights of any person affected by CC0, nor are the rights that other persons may have in the work or in how the work is used, such as publicity or privacy rights.
|
||||
|
||||
Unless expressly stated otherwise, the person who associated a work with this deed makes no warranties about the work, and disclaims liability for all uses of the work, to the fullest extent permitted by applicable law. When using or citing the work, you should not imply endorsement by the author or the affirmer.
|
||||
|
||||
## Contributions to this project
|
||||
|
||||
As stated in [CONTRIBUTING](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.
|
||||
|
||||
## 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.
|
||||
234
Makefile
234
Makefile
@@ -7,76 +7,55 @@ APP_VERSION_FILE = app/version.py
|
||||
GIT_BRANCH ?= $(shell git symbolic-ref --short HEAD 2> /dev/null || echo "detached")
|
||||
GIT_COMMIT ?= $(shell git rev-parse HEAD)
|
||||
|
||||
CF_API ?= api.cloud.service.gov.uk
|
||||
CF_ORG ?= govuk-notify
|
||||
CF_SPACE ?= ${DEPLOY_ENV}
|
||||
CF_HOME ?= ${HOME}
|
||||
$(eval export CF_HOME)
|
||||
|
||||
CF_MANIFEST_PATH ?= /tmp/manifest.yml
|
||||
|
||||
|
||||
NOTIFY_CREDENTIALS ?= ~/.notify-credentials
|
||||
|
||||
|
||||
## DEVELOPMENT
|
||||
|
||||
## TODO this line should go under `make generate-version-file`
|
||||
## poetry self update
|
||||
|
||||
.PHONY: bootstrap
|
||||
bootstrap: ## Set up everything to run the app
|
||||
make generate-version-file
|
||||
poetry self add poetry-dotenv-plugin
|
||||
poetry lock --no-update
|
||||
poetry install --sync --no-root
|
||||
poetry run pre-commit install
|
||||
bootstrap: generate-version-file ## Set up everything to run the app
|
||||
pip3 install -r requirements_for_test.txt
|
||||
createdb notification_api || true
|
||||
createdb test_notification_api || true
|
||||
(poetry run flask db upgrade) || true
|
||||
(. environment.sh && flask db upgrade) || true
|
||||
|
||||
.PHONY: bootstrap-with-docker
|
||||
bootstrap-with-docker: ## Build the image to run the app in Docker
|
||||
docker build -f docker/Dockerfile -t notifications-api .
|
||||
|
||||
.PHONY: run-procfile
|
||||
run-procfile:
|
||||
poetry run honcho start -f Procfile.dev
|
||||
|
||||
|
||||
|
||||
.PHONY: tada
|
||||
tada:
|
||||
poetry run isort .
|
||||
poetry run black .
|
||||
poetry run flake8 .
|
||||
|
||||
.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
|
||||
|
||||
.PHONY: run-flask
|
||||
run-flask: ## Run flask
|
||||
poetry run newrelic-admin run-program flask run -p 6011 --host=0.0.0.0
|
||||
. environment.sh && flask run -p 6011
|
||||
|
||||
.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 \
|
||||
run-celery: ## Run celery
|
||||
. environment.sh && celery \
|
||||
-A run_celery.notify_celery worker \
|
||||
--pidfile="/tmp/celery.pid" \
|
||||
--loglevel=INFO \
|
||||
--pool=threads
|
||||
--concurrency=10
|
||||
--concurrency=4
|
||||
|
||||
|
||||
.PHONY: dead-code
|
||||
dead-code: ## Use 60 to look for suspected dead code
|
||||
poetry run vulture ./app --min-confidence=100
|
||||
.PHONY: run-celery-with-docker
|
||||
run-celery-with-docker: ## Run celery in Docker container (useful if you can't install pycurl locally)
|
||||
./scripts/run_with_docker.sh make run-celery
|
||||
|
||||
.PHONY: run-celery-beat
|
||||
run-celery-beat: ## Run celery beat
|
||||
poetry run celery \
|
||||
-A run_celery.notify_celery beat \
|
||||
--loglevel=INFO
|
||||
. environment.sh && 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: run-celery-beat-with-docker
|
||||
run-celery-beat-with-docker: ## Run celery beat in Docker container (useful if you can't install pycurl locally)
|
||||
./scripts/run_with_docker.sh make run-celery-beat
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@@ -87,36 +66,15 @@ generate-version-file: ## Generates the app version file
|
||||
@echo -e "__git_commit__ = \"${GIT_COMMIT}\"\n__time__ = \"${DATE}\"" > ${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 ./app ./tests
|
||||
poetry run coverage run --omit=*/migrations/*,*/tests/* -m pytest --maxfail=10
|
||||
|
||||
## TODO set this back to 95 asap
|
||||
poetry run coverage report -m --fail-under=93
|
||||
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
|
||||
test: ## Run tests
|
||||
flake8 .
|
||||
isort --check-only ./app ./tests
|
||||
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
|
||||
|
||||
.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
|
||||
|
||||
.PHONY: static-scan
|
||||
static-scan:
|
||||
poetry run bandit -r app/
|
||||
pip install --upgrade pip-tools
|
||||
pip-compile requirements.in
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@@ -125,31 +83,109 @@ clean:
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
# .PHONY: cf-deploy-failwhale
|
||||
# cf-deploy-failwhale:
|
||||
# $(if ${CF_SPACE},,$(error Must target space, eg `make preview cf-deploy-failwhale`))
|
||||
# cd ./paas-failwhale; cf push notify-api-failwhale -f manifest.yml
|
||||
.PHONY: preview
|
||||
preview: ## Set environment to preview
|
||||
$(eval export DEPLOY_ENV=preview)
|
||||
$(eval export DNS_NAME="notify.works")
|
||||
@true
|
||||
|
||||
# .PHONY: enable-failwhale
|
||||
# enable-failwhale: ## Enable the failwhale app and disable api
|
||||
# $(if ${DNS_NAME},,$(error Must target space, eg `make preview enable-failwhale`))
|
||||
# # make sure failwhale is running first
|
||||
# cf start notify-api-failwhale
|
||||
.PHONY: staging
|
||||
staging: ## Set environment to staging
|
||||
$(eval export DEPLOY_ENV=staging)
|
||||
$(eval export DNS_NAME="staging-notify.works")
|
||||
@true
|
||||
|
||||
# cf map-route notify-api-failwhale ${DNS_NAME} --hostname api
|
||||
# cf unmap-route notify-api ${DNS_NAME} --hostname api
|
||||
# @echo "Failwhale is enabled"
|
||||
.PHONY: production
|
||||
production: ## Set environment to production
|
||||
$(eval export DEPLOY_ENV=production)
|
||||
$(eval export DNS_NAME="notifications.service.gov.uk")
|
||||
@true
|
||||
|
||||
# .PHONY: disable-failwhale
|
||||
# disable-failwhale: ## Disable the failwhale app and enable api
|
||||
# $(if ${DNS_NAME},,$(error Must target space, eg `make preview disable-failwhale`))
|
||||
.PHONY: cf-login
|
||||
cf-login: ## Log in to Cloud Foundry
|
||||
$(if ${CF_USERNAME},,$(error Must specify CF_USERNAME))
|
||||
$(if ${CF_PASSWORD},,$(error Must specify CF_PASSWORD))
|
||||
$(if ${CF_SPACE},,$(error Must specify CF_SPACE))
|
||||
@echo "Logging in to Cloud Foundry on ${CF_API}"
|
||||
@cf login -a "${CF_API}" -u ${CF_USERNAME} -p "${CF_PASSWORD}" -o "${CF_ORG}" -s "${CF_SPACE}"
|
||||
|
||||
# cf map-route notify-api ${DNS_NAME} --hostname api
|
||||
# cf unmap-route notify-api-failwhale ${DNS_NAME} --hostname api
|
||||
# cf stop notify-api-failwhale
|
||||
# @echo "Failwhale is disabled"
|
||||
.PHONY: generate-manifest
|
||||
generate-manifest:
|
||||
$(if ${CF_APP},,$(error Must specify CF_APP))
|
||||
$(if ${CF_SPACE},,$(error Must specify CF_SPACE))
|
||||
$(if $(shell which gpg2), $(eval export GPG=gpg2), $(eval export GPG=gpg))
|
||||
$(if ${GPG_PASSPHRASE_TXT}, $(eval export DECRYPT_CMD=echo -n $$$${GPG_PASSPHRASE_TXT} | ${GPG} --quiet --batch --passphrase-fd 0 --pinentry-mode loopback -d), $(eval export DECRYPT_CMD=${GPG} --quiet --batch -d))
|
||||
|
||||
.PHONY: test-single
|
||||
test-single: export NEW_RELIC_ENVIRONMENT=test
|
||||
test-single: ## Run a single test file
|
||||
poetry run pytest -s $(TEST_FILE)
|
||||
@jinja2 --strict manifest.yml.j2 \
|
||||
-D environment=${CF_SPACE} \
|
||||
-D CF_APP=${CF_APP} \
|
||||
--format=yaml \
|
||||
<(${DECRYPT_CMD} ${NOTIFY_CREDENTIALS}/credentials/${CF_SPACE}/paas/environment-variables.gpg) 2>&1
|
||||
|
||||
.PHONY: cf-deploy
|
||||
cf-deploy: ## Deploys the app to Cloud Foundry
|
||||
$(if ${CF_SPACE},,$(error Must specify CF_SPACE))
|
||||
$(if ${CF_APP},,$(error Must specify CF_APP))
|
||||
cf target -o ${CF_ORG} -s ${CF_SPACE}
|
||||
@cf app --guid ${CF_APP} || exit 1
|
||||
|
||||
# cancel any existing deploys to ensure we can apply manifest (if a deploy is in progress you'll see ScaleDisabledDuringDeployment)
|
||||
cf cancel-deployment ${CF_APP} || true
|
||||
|
||||
# generate manifest (including secrets) and write it to CF_MANIFEST_PATH (in /tmp/)
|
||||
make -s CF_APP=${CF_APP} generate-manifest > ${CF_MANIFEST_PATH}
|
||||
|
||||
# fails after 15 mins if deploy doesn't work
|
||||
# reads manifest from CF_MANIFEST_PATH
|
||||
CF_STARTUP_TIMEOUT=15 cf push ${CF_APP} --strategy=rolling -f ${CF_MANIFEST_PATH}
|
||||
# delete old manifest file
|
||||
rm ${CF_MANIFEST_PATH}
|
||||
|
||||
.PHONY: cf-deploy-api-db-migration
|
||||
cf-deploy-api-db-migration:
|
||||
$(if ${CF_SPACE},,$(error Must specify CF_SPACE))
|
||||
cf target -o ${CF_ORG} -s ${CF_SPACE}
|
||||
make -s CF_APP=notify-api-db-migration generate-manifest > ${CF_MANIFEST_PATH}
|
||||
|
||||
cf push notify-api-db-migration --no-route -f ${CF_MANIFEST_PATH}
|
||||
rm ${CF_MANIFEST_PATH}
|
||||
|
||||
cf run-task notify-api-db-migration --command="flask db upgrade" --name api_db_migration
|
||||
|
||||
.PHONY: cf-check-api-db-migration-task
|
||||
cf-check-api-db-migration-task: ## Get the status for the last notify-api-db-migration task
|
||||
@cf curl /v3/apps/`cf app --guid notify-api-db-migration`/tasks?order_by=-created_at | jq -r ".resources[0].state"
|
||||
|
||||
.PHONY: cf-rollback
|
||||
cf-rollback: ## Rollbacks the app to the previous release
|
||||
$(if ${CF_APP},,$(error Must specify CF_APP))
|
||||
rm ${CF_MANIFEST_PATH}
|
||||
cf cancel-deployment ${CF_APP}
|
||||
|
||||
.PHONY: check-if-migrations-to-run
|
||||
check-if-migrations-to-run:
|
||||
@echo $(shell python3 scripts/check_if_new_migration.py)
|
||||
|
||||
.PHONY: cf-deploy-failwhale
|
||||
cf-deploy-failwhale:
|
||||
$(if ${CF_SPACE},,$(error Must target space, eg `make preview cf-deploy-failwhale`))
|
||||
cd ./paas-failwhale; cf push notify-api-failwhale -f manifest.yml
|
||||
|
||||
.PHONY: enable-failwhale
|
||||
enable-failwhale: ## Enable the failwhale app and disable api
|
||||
$(if ${DNS_NAME},,$(error Must target space, eg `make preview enable-failwhale`))
|
||||
# make sure failwhale is running first
|
||||
cf start notify-api-failwhale
|
||||
|
||||
cf map-route notify-api-failwhale ${DNS_NAME} --hostname api
|
||||
cf unmap-route notify-api ${DNS_NAME} --hostname api
|
||||
@echo "Failwhale is enabled"
|
||||
|
||||
.PHONY: disable-failwhale
|
||||
disable-failwhale: ## Disable the failwhale app and enable api
|
||||
$(if ${DNS_NAME},,$(error Must target space, eg `make preview disable-failwhale`))
|
||||
|
||||
cf map-route notify-api ${DNS_NAME} --hostname api
|
||||
cf unmap-route notify-api-failwhale ${DNS_NAME} --hostname api
|
||||
cf stop notify-api-failwhale
|
||||
@echo "Failwhale is disabled"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
web: make run-flask
|
||||
worker: make run-celery
|
||||
scheduler: make run-celery-beat
|
||||
568
README.md
568
README.md
@@ -1,531 +1,139 @@
|
||||

|
||||
# GOV.UK Notify API
|
||||
|
||||
# Notify.gov API
|
||||
Contains:
|
||||
- the public-facing REST API for GOV.UK Notify, which teams can integrate with using [our clients](https://www.notifications.service.gov.uk/documentation)
|
||||
- an internal-only REST API built using Flask to manage services, users, templates, etc (this is what the [admin app](http://github.com/alphagov/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
|
||||
|
||||
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!
|
||||
## Setting Up
|
||||
|
||||
This repo contains:
|
||||
### Python version
|
||||
|
||||
- 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.
|
||||
We run python 3.9 both locally and in production.
|
||||
|
||||
Our other repositories are:
|
||||
### psycopg2
|
||||
|
||||
- [notifications-admin](https://github.com/GSA/notifications-admin)
|
||||
- [us-notify-compliance](https://github.com/GSA/us-notify-compliance/)
|
||||
- [notify-python-demo](https://github.com/GSA/notify-python-demo)
|
||||
[Follow these instructions on Mac M1 machines](https://github.com/psycopg/psycopg2/issues/1216#issuecomment-1068150544).
|
||||
|
||||
## Before You Start
|
||||
### AWS credentials
|
||||
|
||||
You will need the following items:
|
||||
To run the API you will need appropriate AWS credentials. See the [Wiki](https://github.com/alphagov/notifications-manuals/wiki/aws-accounts#how-to-set-up-local-development) for more details.
|
||||
|
||||
- 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.
|
||||
### `environment.sh`
|
||||
|
||||
## Local Environment Setup
|
||||
|
||||
This project currently works with these major versions of the following main
|
||||
components:
|
||||
|
||||
- Python 3.12.x
|
||||
- PostgreSQL 15.x (version 12.x is used in the hosted environments)
|
||||
|
||||
These instructions will walk you through how to set your machine up with all of
|
||||
the required tools for this project.
|
||||
|
||||
### Project Pre-Requisite Setup
|
||||
|
||||
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.
|
||||
|
||||
**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:
|
||||
|
||||
```sh
|
||||
xcode-select –-install
|
||||
```
|
||||
|
||||
#### Homebrew Setup
|
||||
|
||||
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.
|
||||
|
||||
Your system `$PATH` environment variable is likely set in one of these
|
||||
locations:
|
||||
|
||||
For BASH shells:
|
||||
- `~/.bashrc`
|
||||
- `~/.bash_profile`
|
||||
- `~/.profile`
|
||||
|
||||
For ZSH shells:
|
||||
- `~/.zshrc`
|
||||
- `~/.zprofile`
|
||||
|
||||
There may be different files that you need to modify for other shell
|
||||
environments.
|
||||
|
||||
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!
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
# Homebrew setup
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Verify that Homebrew is now working by trying to run `brew help` again.
|
||||
|
||||
### System-Level Package Installation
|
||||
|
||||
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).
|
||||
|
||||
Start off with these packages since they're quick and don't require additional
|
||||
configuration after installation to get working out of the box:
|
||||
|
||||
- [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:
|
||||
|
||||
```sh
|
||||
brew install jq git tfenv cloudfoundry/tap/cf-cli@8 redis vim wget
|
||||
```
|
||||
|
||||
#### Terraform Installation
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
tfenv install "latest:^1.7"
|
||||
tfenv use 1.7.x # x = the patch version installed
|
||||
```
|
||||
|
||||
#### Python Installation
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
brew install pyenv pyenv-virtualenv
|
||||
```
|
||||
|
||||
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:
|
||||
Creating and edit an environment.sh file.
|
||||
|
||||
```
|
||||
# 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 -)"
|
||||
echo "
|
||||
export NOTIFY_ENVIRONMENT='development'
|
||||
|
||||
export MMG_API_KEY='MMG_API_KEY'
|
||||
export FIRETEXT_API_KEY='FIRETEXT_ACTUAL_KEY'
|
||||
export REACH_API_KEY='REACH_API_KEY'
|
||||
export NOTIFICATION_QUEUE_PREFIX='YOUR_OWN_PREFIX'
|
||||
|
||||
export FLASK_APP=application.py
|
||||
export FLASK_ENV=development
|
||||
export WERKZEUG_DEBUG_PIN=off
|
||||
"> environment.sh
|
||||
```
|
||||
|
||||
Once again, start a new shell session or source the file in your current shell
|
||||
session to make the changes take effect.
|
||||
Things to change:
|
||||
|
||||
Now we're ready to install the Python version we need with `pyenv`, like so:
|
||||
|
||||
```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:
|
||||
* Replace `YOUR_OWN_PREFIX` with `local_dev_<first name>`.
|
||||
* Run the following in the credentials repo to get the API keys.
|
||||
|
||||
```
|
||||
/opt/homebrew/opt/postgresql@15/bin
|
||||
notify-pass credentials/firetext
|
||||
notify-pass credentials/mmg
|
||||
notify-pass credentials/reach
|
||||
```
|
||||
|
||||
If you don't have a line for your `$PATH` environment variable, add it in like
|
||||
this, which will include the PostgreSQL binaries:
|
||||
### Postgres
|
||||
|
||||
Install [Postgres.app](http://postgresapp.com/).
|
||||
|
||||
Currently the API works with PostgreSQL 11. After installation, open the Postgres app, open the sidebar, and update or replace the default server with a compatible version.
|
||||
|
||||
**Note:** you may need to add the following directory to your PATH in order to bootstrap the app.
|
||||
|
||||
```
|
||||
export PATH="/opt/homebrew/opt/postgresql@15/bin:$PATH
|
||||
export PATH=${PATH}:/Applications/Postgres.app/Contents/Versions/11/bin/
|
||||
```
|
||||
|
||||
_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._
|
||||
### Redis
|
||||
|
||||
#### Starting PostgreSQL and Redis
|
||||
To switch redis on you'll need to install it locally. On a Mac you can do:
|
||||
|
||||
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
|
||||
```
|
||||
# assuming you use Homebrew
|
||||
brew install redis
|
||||
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:
|
||||
To use redis caching you need to switch it on with an environment variable:
|
||||
|
||||
```
|
||||
#############################################################
|
||||
|
||||
# E2E Testing
|
||||
|
||||
NOTIFY_E2E_TEST_EMAIL=example@fake.gov
|
||||
NOTIFY_E2E_TEST_PASSWORD="don't write secrets to the sample file"
|
||||
export REDIS_ENABLED=1
|
||||
```
|
||||
|
||||
You can leave the email address alone or change it to something else to your
|
||||
liking.
|
||||
## To run the application
|
||||
|
||||
**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
|
||||
```
|
||||
# install dependencies, etc.
|
||||
make bootstrap
|
||||
|
||||
# run the web app
|
||||
make run-flask
|
||||
|
||||
# run the background tasks
|
||||
make run-celery
|
||||
|
||||
# run scheduled tasks (optional)
|
||||
make run-celery-beat
|
||||
```
|
||||
|
||||
This command is handled by the `Makefile` file in the root project directory, as
|
||||
are a few others.
|
||||
We've had problems running Celery locally due to one of its dependencies: pycurl. Due to the complexity of the issue, we also support running Celery via Docker:
|
||||
|
||||
_NOTE: You'll want to occasionally run `make bootstrap` to keep your project up-to-date, especially when there are dependency updates._
|
||||
```
|
||||
# install dependencies, etc.
|
||||
make bootstrap-with-docker
|
||||
|
||||
Now you can run the web server and background workers for asynchronous jobs:
|
||||
# run the background tasks
|
||||
make run-celery-with-docker
|
||||
|
||||
```sh
|
||||
make run-procfile
|
||||
# run scheduled tasks
|
||||
make run-celery-beat-with-docker
|
||||
```
|
||||
|
||||
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.
|
||||
## To test the application
|
||||
|
||||
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:
|
||||
```
|
||||
# install dependencies, etc.
|
||||
make bootstrap
|
||||
|
||||
- `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>...]
|
||||
make test
|
||||
```
|
||||
|
||||
You can specify more than one dependency together. With this command, Poetry
|
||||
will do the following for you:
|
||||
## To run one off tasks
|
||||
|
||||
- Find the latest compatible version(s) of the specified dependency/dependencies
|
||||
- Install the new versions
|
||||
- Update and sync the `poetry.lock` file
|
||||
Tasks are run through the `flask` command - run `flask --help` for more information. There are two sections we need to
|
||||
care about: `flask db` contains alembic migration commands, and `flask command` contains all of our custom commands. For
|
||||
example, to purge all dynamically generated functional test data, do the following:
|
||||
|
||||
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.
|
||||
Locally
|
||||
```
|
||||
flask command purge_functional_test_data -u <functional tests user name prefix>
|
||||
```
|
||||
|
||||
## Known Installation Issues
|
||||
On the server
|
||||
```
|
||||
cf run-task notify-api "flask command purge_functional_test_data -u <functional tests user name prefix>"
|
||||
```
|
||||
|
||||
### Python Installation Errors
|
||||
All commands and command options have a --help command if you need more information.
|
||||
|
||||
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.
|
||||
## Further documentation
|
||||
|
||||
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)
|
||||
- [Pull Requests](.docs/all.md#pull-requests)
|
||||
- [Getting Started](.docs/all.md#getting-started)
|
||||
- [Description](.docs/all.md#description)
|
||||
- [TODO (optional)](.docs/all.md#todo-(optional))
|
||||
- [Security Considerations](.docs/all.md#security-considerations)
|
||||
- [Code Reviews](.docs/all.md#code-reviews)
|
||||
- [For the reviewer](.docs/all.md#for-the-reviewer)
|
||||
- [For the author](.docs/all.md#for-the-author)
|
||||
- [Run Book](./docs/all.md#run-book)
|
||||
- [Alerts, Notifications, Monitoring](./docs/all.md#-alerts-notifications-monitoring)
|
||||
- [Restaging Apps](./docs/all.md#-restaging-apps)
|
||||
- [Deploying to Production](./docs/all.md#-deploying-to-production)
|
||||
- [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)
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
- [Writing public APIs](docs/writing-public-apis.md)
|
||||
- [Updating dependencies](https://github.com/alphagov/notifications-manuals/wiki/Dependencies)
|
||||
|
||||
436
app/__init__.py
436
app/__init__.py
@@ -1,136 +1,118 @@
|
||||
import logging as real_logging
|
||||
import os
|
||||
import secrets
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from multiprocessing import Manager
|
||||
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 import config
|
||||
from app.clients import NotificationProviderClients
|
||||
from app.clients.cloudwatch.aws_cloudwatch import AwsCloudwatchClient
|
||||
from app.clients.cbc_proxy import CBCProxyClient
|
||||
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.pinpoint.aws_pinpoint import AwsPinpointClient
|
||||
from app.clients.sms.aws_sns import AwsSnsClient
|
||||
from notifications_utils import logging, request_helper
|
||||
from notifications_utils.clients.encryption.encryption_client import Encryption
|
||||
from notifications_utils.clients.redis.redis_client import RedisClient
|
||||
from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient
|
||||
|
||||
|
||||
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"])
|
||||
self.conf.worker_hijack_root_logger = False
|
||||
logger = real_logging.getLogger("celery")
|
||||
logger.propagate = False
|
||||
|
||||
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)
|
||||
from app.clients.sms.firetext import FiretextClient
|
||||
from app.clients.sms.mmg import MMGClient
|
||||
from app.clients.sms.reach import ReachClient
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
return (sa_url, options)
|
||||
|
||||
|
||||
# Set db engine settings here for now.
|
||||
# They were not being set previous (despite environmental variables with appropriate
|
||||
# sounding names) and were defaulting to low values
|
||||
db = SQLAlchemy(
|
||||
engine_options={
|
||||
"pool_size": config.Config.SQLALCHEMY_POOL_SIZE,
|
||||
"max_overflow": 10,
|
||||
"pool_timeout": config.Config.SQLALCHEMY_POOL_TIMEOUT,
|
||||
"pool_recycle": config.Config.SQLALCHEMY_POOL_RECYCLE,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
)
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
ma = Marshmallow()
|
||||
notify_celery = NotifyCelery()
|
||||
firetext_client = FiretextClient()
|
||||
mmg_client = MMGClient()
|
||||
reach_client = ReachClient()
|
||||
aws_ses_client = AwsSesClient()
|
||||
aws_ses_stub_client = AwsSesStubClient()
|
||||
aws_sns_client = AwsSnsClient()
|
||||
aws_cloudwatch_client = AwsCloudwatchClient()
|
||||
aws_pinpoint_client = AwsPinpointClient()
|
||||
encryption = Encryption()
|
||||
zendesk_client = ZendeskClient()
|
||||
statsd_client = StatsdClient()
|
||||
redis_store = RedisClient()
|
||||
cbc_proxy_client = CBCProxyClient()
|
||||
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)
|
||||
logging.init_app(application)
|
||||
aws_sns_client.init_app(application)
|
||||
statsd_client.init_app(application)
|
||||
logging.init_app(application, statsd_client)
|
||||
firetext_client.init_app(application, statsd_client=statsd_client)
|
||||
mmg_client.init_app(application, statsd_client=statsd_client)
|
||||
reach_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)
|
||||
aws_pinpoint_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(application.config['AWS_REGION'], statsd_client=statsd_client)
|
||||
aws_ses_stub_client.init_app(
|
||||
application.config['AWS_REGION'],
|
||||
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=[firetext_client, mmg_client, reach_client],
|
||||
email_clients=email_clients
|
||||
)
|
||||
|
||||
notify_celery.init_app(application)
|
||||
@@ -138,14 +120,13 @@ def create_app(application):
|
||||
redis_store.init_app(application)
|
||||
document_download_client.init_app(application)
|
||||
|
||||
manager = Manager()
|
||||
application.config["job_cache"] = manager.dict()
|
||||
cbc_proxy_client.init_app(application)
|
||||
|
||||
register_blueprint(application)
|
||||
register_v2_blueprints(application)
|
||||
|
||||
# avoid circular imports by importing this file later
|
||||
from app.commands import setup_commands
|
||||
|
||||
setup_commands(application)
|
||||
|
||||
# set up sqlalchemy events
|
||||
@@ -158,27 +139,44 @@ def register_blueprint(application):
|
||||
from app.authentication.auth import (
|
||||
requires_admin_auth,
|
||||
requires_auth,
|
||||
requires_govuk_alerts_auth,
|
||||
requires_no_auth,
|
||||
)
|
||||
from app.billing.rest import billing_blueprint
|
||||
from app.broadcast_message.rest import broadcast_message_blueprint
|
||||
from app.complaint.complaint_rest import complaint_blueprint
|
||||
from app.docs import docs as docs_blueprint
|
||||
from app.email_branding.rest import email_branding_blueprint
|
||||
from app.events.rest import events as events_blueprint
|
||||
from app.govuk_alerts.rest import govuk_alerts_blueprint
|
||||
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.letter_branding.letter_branding_rest import (
|
||||
letter_branding_blueprint,
|
||||
)
|
||||
from app.letters.rest import letter_job
|
||||
from app.notifications.notifications_letter_callback import (
|
||||
letter_callback_blueprint,
|
||||
)
|
||||
from app.notifications.notifications_sms_callback import (
|
||||
sms_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
|
||||
@@ -190,10 +188,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)
|
||||
@@ -204,12 +202,10 @@ def register_blueprint(application):
|
||||
status_blueprint.before_request(requires_no_auth)
|
||||
application.register_blueprint(status_blueprint)
|
||||
|
||||
docs_blueprint.before_request(requires_no_auth)
|
||||
application.register_blueprint(docs_blueprint)
|
||||
|
||||
# delivery receipts
|
||||
ses_callback_blueprint.before_request(requires_no_auth)
|
||||
application.register_blueprint(ses_callback_blueprint)
|
||||
# TODO: make sure research mode can still trigger sms callbacks, then re-enable this
|
||||
sms_callback_blueprint.before_request(requires_no_auth)
|
||||
application.register_blueprint(sms_callback_blueprint)
|
||||
|
||||
# inbound sms
|
||||
receive_notifications_blueprint.before_request(requires_no_auth)
|
||||
@@ -224,8 +220,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)
|
||||
@@ -240,14 +236,16 @@ 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')
|
||||
|
||||
letter_job.before_request(requires_admin_auth)
|
||||
application.register_blueprint(letter_job)
|
||||
|
||||
letter_callback_blueprint.before_request(requires_no_auth)
|
||||
application.register_blueprint(letter_callback_blueprint)
|
||||
|
||||
billing_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(billing_blueprint)
|
||||
@@ -255,8 +253,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)
|
||||
@@ -265,64 +263,94 @@ 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)
|
||||
|
||||
letter_branding_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(letter_branding_blueprint)
|
||||
|
||||
upload_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(upload_blueprint)
|
||||
|
||||
broadcast_message_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(broadcast_message_blueprint)
|
||||
|
||||
govuk_alerts_blueprint.before_request(requires_govuk_alerts_auth)
|
||||
application.register_blueprint(govuk_alerts_blueprint)
|
||||
|
||||
|
||||
def register_v2_blueprints(application):
|
||||
from app.authentication.auth import requires_auth
|
||||
from app.v2.broadcast.post_broadcast import v2_broadcast_blueprint
|
||||
from app.v2.inbound_sms.get_inbound_sms import v2_inbound_sms_blueprint
|
||||
from app.v2.notifications import ( # noqa
|
||||
get_notifications,
|
||||
post_notifications,
|
||||
v2_notification_blueprint,
|
||||
)
|
||||
from app.v2.template import ( # noqa
|
||||
get_template,
|
||||
post_template,
|
||||
v2_template_blueprint,
|
||||
)
|
||||
from app.v2.templates.get_templates import v2_templates_blueprint
|
||||
|
||||
v2_notification_blueprint.before_request(requires_auth)
|
||||
application.register_blueprint(v2_notification_blueprint)
|
||||
|
||||
v2_templates_blueprint.before_request(requires_auth)
|
||||
application.register_blueprint(v2_templates_blueprint)
|
||||
|
||||
v2_template_blueprint.before_request(requires_auth)
|
||||
application.register_blueprint(v2_template_blueprint)
|
||||
|
||||
v2_inbound_sms_blueprint.before_request(requires_auth)
|
||||
application.register_blueprint(v2_inbound_sms_blueprint)
|
||||
|
||||
v2_broadcast_blueprint.before_request(requires_auth)
|
||||
application.register_blueprint(v2_broadcast_blueprint)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
# Some dynamic scan findings
|
||||
response.headers.add("Cross-Origin-Opener-Policy", "same-origin")
|
||||
response.headers.add("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
response.headers.add("Cross-Origin-Resource-Policy", "same-origin")
|
||||
response.headers.add("Cross-Origin-Opener-Policy", "same-origin")
|
||||
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(f"Handling error: {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():
|
||||
@@ -330,29 +358,47 @@ def create_uuid():
|
||||
|
||||
|
||||
def create_random_identifier():
|
||||
return "".join(
|
||||
secrets.choice(string.ascii_uppercase + string.digits) for _ in range(16)
|
||||
return ''.join(random.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.
|
||||
@@ -360,90 +406,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.",
|
||||
)
|
||||
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,7 +1,7 @@
|
||||
import os
|
||||
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,
|
||||
@@ -13,18 +13,17 @@ from notifications_python_client.errors import (
|
||||
TokenExpiredError,
|
||||
TokenIssuerError,
|
||||
)
|
||||
from notifications_utils import request_helper
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from app.serialised_models import SerialisedService
|
||||
from notifications_utils import request_helper
|
||||
|
||||
# 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
|
||||
GENERAL_TOKEN_ERROR_MESSAGE = 'Invalid token: make sure your API token matches the example at https://docs.notifications.service.gov.uk/rest-api.html#authorisation-header' # noqa
|
||||
|
||||
AUTH_DB_CONNECTION_DURATION_SECONDS = Histogram(
|
||||
'auth_db_connection_duration_seconds',
|
||||
'Time taken to get DB connection and fetch service from database',
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
@@ -36,18 +35,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,34 +60,28 @@ def requires_no_auth():
|
||||
pass
|
||||
|
||||
|
||||
def requires_govuk_alerts_auth():
|
||||
requires_internal_auth(current_app.config.get('GOVUK_ALERTS_CLIENT_ID'))
|
||||
|
||||
|
||||
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):
|
||||
|
||||
# Looks like we are hitting this for some reason
|
||||
# expected_client_id looks like ADMIN_CLIENT_USERNAME on the admin side, and
|
||||
# INTERNAL_CLIENT_API_KEYS is a dict
|
||||
keys = current_app.config.get("INTERNAL_CLIENT_API_KEYS")
|
||||
if keys.get(expected_client_id) is None:
|
||||
err_msg = "Unknown client_id for internal auth"
|
||||
current_app.logger.error(err_msg)
|
||||
raise TypeError(err_msg)
|
||||
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()
|
||||
auth_token = _get_auth_token(request)
|
||||
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)
|
||||
err_msg = "Unauthorized: not allowed to perform this action"
|
||||
current_app.logger.error(err_msg)
|
||||
raise AuthError(err_msg, 401)
|
||||
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)
|
||||
@@ -96,9 +92,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)
|
||||
@@ -106,27 +100,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
|
||||
@@ -134,37 +126,14 @@ def requires_auth():
|
||||
|
||||
|
||||
def _decode_jwt_token(auth_token, api_keys, service_id=None):
|
||||
# Temporary expedient to get e2e tests working. If we are in
|
||||
# the development or staging environments, just return the first
|
||||
# api key.
|
||||
if os.getenv("NOTIFY_ENVIRONMENT") in ["development", "staging"]:
|
||||
for api_key in api_keys:
|
||||
return api_key
|
||||
|
||||
for api_key in api_keys:
|
||||
try:
|
||||
decode_jwt_token(auth_token, api_key.secret)
|
||||
except TypeError:
|
||||
err_msg = "Invalid token: type error"
|
||||
current_app.logger.exception(err_msg)
|
||||
raise AuthError(
|
||||
"Invalid token: type error",
|
||||
403,
|
||||
service_id=service_id,
|
||||
api_key_id=api_key.id,
|
||||
)
|
||||
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"
|
||||
)
|
||||
current_app.logger.exception(err_msg)
|
||||
raise AuthError(
|
||||
err_msg, 403, service_id=service_id, api_key_id=api_key.id
|
||||
)
|
||||
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"
|
||||
current_app.logger.exception(err_msg)
|
||||
raise AuthError(err_msg, 403, service_id=service_id, api_key_id=api_key.id)
|
||||
except TokenDecodeError:
|
||||
# we attempted to validate the token but it failed meaning it was not signed using this api key.
|
||||
@@ -172,48 +141,29 @@ def _decode_jwt_token(auth_token, api_keys, service_id=None):
|
||||
# TODO: Change this so it doesn't also catch `TokenIssuerError` or `TokenIssuedAtError` exceptions (which
|
||||
# are children of `TokenDecodeError`) as these should cause an auth error immediately rather than
|
||||
# continue on to check the next API key
|
||||
current_app.logger.exception(
|
||||
"TokenDecodeError. Couldn't decode auth token for given api key"
|
||||
)
|
||||
continue
|
||||
except TokenError:
|
||||
current_app.logger.exception("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:
|
||||
err_msg = "Invalid token: API key revoked"
|
||||
current_app.logger.error(err_msg, exc_info=True)
|
||||
raise AuthError(
|
||||
err_msg,
|
||||
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:
|
||||
# service has API keys, but none matching the one the user provided
|
||||
# if we get here, we probably hit TokenDecodeErrors earlier
|
||||
err_msg = "Invalid token: API key not found"
|
||||
current_app.logger.error(err_msg, exc_info=True)
|
||||
raise AuthError(err_msg, 403, service_id=service_id)
|
||||
raise AuthError("Invalid token: API key not found", 403, service_id=service_id)
|
||||
|
||||
|
||||
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:]
|
||||
|
||||
|
||||
538
app/aws/s3.py
538
app/aws/s3.py
@@ -1,533 +1,91 @@
|
||||
import csv
|
||||
import datetime
|
||||
import re
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import StringIO
|
||||
|
||||
import botocore
|
||||
from boto3 import Session
|
||||
from boto3 import client, resource
|
||||
from flask import current_app
|
||||
|
||||
from app.clients import AWS_CLIENT_CONFIG
|
||||
from notifications_utils import aware_utcnow
|
||||
|
||||
FILE_LOCATION_STRUCTURE = "service-{}-notify/{}.csv"
|
||||
NEW_FILE_LOCATION_STRUCTURE = "{}-service-notify/{}.csv"
|
||||
|
||||
# Temporarily extend cache to 7 days
|
||||
ttl = 60 * 60 * 24 * 7
|
||||
FILE_LOCATION_STRUCTURE = 'service-{}-notify/{}.csv'
|
||||
|
||||
|
||||
# Global variable
|
||||
s3_client = None
|
||||
s3_resource = None
|
||||
def get_s3_file(bucket_name, file_location):
|
||||
s3_file = get_s3_object(bucket_name, file_location)
|
||||
return s3_file.get()['Body'].read().decode('utf-8')
|
||||
|
||||
|
||||
def set_job_cache(key, value):
|
||||
current_app.logger.debug(f"Setting {key} in the job_cache.")
|
||||
job_cache = current_app.config["job_cache"]
|
||||
job_cache[key] = (value, time.time() + 8 * 24 * 60 * 60)
|
||||
def get_s3_object(bucket_name, file_location):
|
||||
s3 = resource('s3')
|
||||
return s3.Object(bucket_name, file_location)
|
||||
|
||||
|
||||
def get_job_cache(key):
|
||||
job_cache = current_app.config["job_cache"]
|
||||
ret = job_cache.get(key)
|
||||
if ret is None:
|
||||
current_app.logger.warning(f"Could not find {key} in the job_cache.")
|
||||
else:
|
||||
current_app.logger.debug(f"Got {key} from job_cache.")
|
||||
return ret
|
||||
def head_s3_object(bucket_name, file_location):
|
||||
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.head_object
|
||||
boto_client = client('s3', current_app.config['AWS_REGION'])
|
||||
return boto_client.head_object(Bucket=bucket_name, Key=file_location)
|
||||
|
||||
|
||||
def len_job_cache():
|
||||
job_cache = current_app.config["job_cache"]
|
||||
ret = len(job_cache)
|
||||
current_app.logger.debug(f"Length of job_cache is {ret}")
|
||||
return ret
|
||||
|
||||
|
||||
def clean_cache():
|
||||
job_cache = current_app.config["job_cache"]
|
||||
current_time = time.time()
|
||||
keys_to_delete = []
|
||||
for key, (_, expiry_time) in job_cache.items():
|
||||
if expiry_time < current_time:
|
||||
keys_to_delete.append(key)
|
||||
|
||||
current_app.logger.debug(
|
||||
f"Deleting the following keys from the job_cache: {keys_to_delete}"
|
||||
)
|
||||
for key in keys_to_delete:
|
||||
del job_cache[key]
|
||||
|
||||
|
||||
def get_s3_client():
|
||||
global s3_client
|
||||
if s3_client is None:
|
||||
access_key = current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"]
|
||||
secret_key = current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"]
|
||||
region = current_app.config["CSV_UPLOAD_BUCKET"]["region"]
|
||||
session = Session(
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
)
|
||||
s3_client = session.client("s3")
|
||||
return s3_client
|
||||
|
||||
|
||||
def get_s3_resource():
|
||||
global s3_resource
|
||||
if s3_resource is None:
|
||||
access_key = current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"]
|
||||
secret_key = current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"]
|
||||
region = current_app.config["CSV_UPLOAD_BUCKET"]["region"]
|
||||
session = Session(
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
)
|
||||
s3_resource = session.resource("s3", config=AWS_CLIENT_CONFIG)
|
||||
return s3_resource
|
||||
|
||||
|
||||
def _get_bucket_name():
|
||||
return current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
|
||||
|
||||
|
||||
def list_s3_objects():
|
||||
|
||||
bucket_name = _get_bucket_name()
|
||||
s3_client = get_s3_client()
|
||||
# Our reports only support 7 days, but pull 8 days to avoid
|
||||
# any edge cases
|
||||
time_limit = aware_utcnow() - datetime.timedelta(days=8)
|
||||
try:
|
||||
response = s3_client.list_objects_v2(Bucket=bucket_name)
|
||||
while True:
|
||||
for obj in response.get("Contents", []):
|
||||
if obj["LastModified"] >= time_limit:
|
||||
yield obj["Key"]
|
||||
if "NextContinuationToken" in response:
|
||||
response = s3_client.list_objects_v2(
|
||||
Bucket=bucket_name,
|
||||
ContinuationToken=response["NextContinuationToken"],
|
||||
)
|
||||
else:
|
||||
break
|
||||
except Exception:
|
||||
current_app.logger.exception(
|
||||
"An error occurred while regenerating cache #notify-debug-admin-1200",
|
||||
)
|
||||
|
||||
|
||||
def get_bucket_name():
|
||||
return current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
|
||||
|
||||
|
||||
def cleanup_old_s3_objects():
|
||||
bucket_name = get_bucket_name()
|
||||
|
||||
s3_client = get_s3_client()
|
||||
# Our reports only support 7 days, but can be scheduled 3 days in advance
|
||||
# Use 14 day for the v1.0 version of this behavior
|
||||
time_limit = aware_utcnow() - datetime.timedelta(days=14)
|
||||
try:
|
||||
response = s3_client.list_objects_v2(Bucket=bucket_name)
|
||||
while True:
|
||||
for obj in response.get("Contents", []):
|
||||
if obj["LastModified"] <= time_limit:
|
||||
|
||||
try:
|
||||
remove_csv_object(obj["Key"])
|
||||
current_app.logger.debug(
|
||||
f"#delete-old-s3-objects Deleted: {obj['LastModified']} {obj['Key']}"
|
||||
)
|
||||
except botocore.exceptions.ClientError:
|
||||
current_app.logger.exception(f"Couldn't delete {obj['Key']}")
|
||||
|
||||
if "NextContinuationToken" in response:
|
||||
response = s3_client.list_objects_v2(
|
||||
Bucket=bucket_name,
|
||||
ContinuationToken=response["NextContinuationToken"],
|
||||
)
|
||||
else:
|
||||
break
|
||||
except Exception:
|
||||
current_app.logger.exception(
|
||||
"#delete-old-s3-objects An error occurred while cleaning up old s3 objects",
|
||||
)
|
||||
|
||||
|
||||
def get_job_id_from_s3_object_key(key):
|
||||
object_arr = key.split("/")
|
||||
job_id = object_arr[1] # get the job_id
|
||||
job_id = job_id.replace(".csv", "") # we just want the job_id
|
||||
return job_id
|
||||
|
||||
|
||||
def read_s3_file(bucket_name, object_key, s3res):
|
||||
"""
|
||||
This method runs during the 'regenerate job cache' task.
|
||||
Note that in addition to retrieving the jobs and putting them
|
||||
into the cache, this method also does some pre-processing by
|
||||
putting a list of all phone numbers into the cache as well.
|
||||
|
||||
This means that when the report needs to be regenerated, it
|
||||
can easily find the phone numbers in the cache through job_cache[<job_id>_phones]
|
||||
and the personalization through job_cache[<job_id>_personalisation], which
|
||||
in theory should make report generation a lot faster.
|
||||
|
||||
We are moving processing from the front end where the user can see it
|
||||
in wait time, to this back end process.
|
||||
"""
|
||||
try:
|
||||
job_id = get_job_id_from_s3_object_key(object_key)
|
||||
if get_job_cache(job_id) is None:
|
||||
object = (
|
||||
s3res.Object(bucket_name, object_key)
|
||||
.get()["Body"]
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
set_job_cache(job_id, object)
|
||||
set_job_cache(f"{job_id}_phones", extract_phones(object))
|
||||
set_job_cache(
|
||||
f"{job_id}_personalisation",
|
||||
extract_personalisation(object),
|
||||
)
|
||||
|
||||
except LookupError:
|
||||
# perhaps our key is not formatted as we expected. If so skip it.
|
||||
current_app.logger.exception("LookupError #notify-debug-admin-1200")
|
||||
|
||||
|
||||
def get_s3_files():
|
||||
"""
|
||||
We're using the ThreadPoolExecutor here to speed up the retrieval of S3
|
||||
csv files for scaling needs.
|
||||
"""
|
||||
bucket_name = current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
|
||||
object_keys = list_s3_objects()
|
||||
|
||||
s3res = get_s3_resource()
|
||||
current_app.logger.info(
|
||||
f"job_cache length before regen: {len_job_cache()} #notify-debug-admin-1200"
|
||||
)
|
||||
try:
|
||||
with ThreadPoolExecutor() as executor:
|
||||
executor.map(lambda key: read_s3_file(bucket_name, key, s3res), object_keys)
|
||||
except Exception:
|
||||
current_app.logger.exception("Connection pool issue")
|
||||
|
||||
current_app.logger.info(
|
||||
f"job_cache length after regen: {len_job_cache()} #notify-debug-admin-1200"
|
||||
)
|
||||
|
||||
|
||||
def get_s3_file(bucket_name, file_location, access_key, secret_key, region):
|
||||
s3_file = get_s3_object(bucket_name, file_location, access_key, secret_key, region)
|
||||
return s3_file.get()["Body"].read().decode("utf-8")
|
||||
|
||||
|
||||
def download_from_s3(
|
||||
bucket_name, s3_key, local_filename, access_key, secret_key, region
|
||||
):
|
||||
|
||||
s3 = get_s3_client()
|
||||
result = None
|
||||
try:
|
||||
result = s3.download_file(bucket_name, s3_key, local_filename)
|
||||
current_app.logger.info(f"File downloaded successfully to {local_filename}")
|
||||
except botocore.exceptions.NoCredentialsError as nce:
|
||||
current_app.logger.exception("Credentials not found")
|
||||
raise Exception(nce)
|
||||
except botocore.exceptions.PartialCredentialsError as pce:
|
||||
current_app.logger.exception("Incomplete credentials provided")
|
||||
raise Exception(pce)
|
||||
except Exception:
|
||||
current_app.logger.exception("An error occurred")
|
||||
text = f"EXCEPTION local_filename {local_filename}"
|
||||
raise Exception(text)
|
||||
return result
|
||||
|
||||
|
||||
def get_s3_object(bucket_name, file_location, access_key, secret_key, region):
|
||||
|
||||
s3 = get_s3_resource()
|
||||
try:
|
||||
return s3.Object(bucket_name, file_location)
|
||||
except botocore.exceptions.ClientError:
|
||||
current_app.logger.exception(
|
||||
f"Can't retrieve S3 Object from {file_location}",
|
||||
)
|
||||
|
||||
|
||||
def purge_bucket(bucket_name, access_key, secret_key, region):
|
||||
s3 = get_s3_resource()
|
||||
bucket = s3.Bucket(bucket_name)
|
||||
bucket.objects.all().delete()
|
||||
|
||||
|
||||
def file_exists(file_location):
|
||||
bucket_name = current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
|
||||
access_key = current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"]
|
||||
secret_key = current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"]
|
||||
region = current_app.config["CSV_UPLOAD_BUCKET"]["region"]
|
||||
|
||||
def file_exists(bucket_name, file_location):
|
||||
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).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):
|
||||
current_app.logger.debug(
|
||||
f"#notify-debug-s3-partitioning NEW JOB_LOCATION: {NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id)}"
|
||||
)
|
||||
return (
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
|
||||
NEW_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"],
|
||||
)
|
||||
|
||||
|
||||
def get_old_job_location(service_id, job_id):
|
||||
"""
|
||||
This is deprecated. We are transitioning to NEW_FILE_LOCATION_STRUCTURE,
|
||||
but it will take a few days where we have to support both formats.
|
||||
Remove this when everything works with the NEW_FILE_LOCATION_STRUCTURE.
|
||||
"""
|
||||
current_app.logger.debug(
|
||||
f"#notify-debug-s3-partitioning OLD JOB LOCATION: {FILE_LOCATION_STRUCTURE.format(service_id, job_id)}"
|
||||
)
|
||||
return (
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
|
||||
current_app.config['CSV_UPLOAD_BUCKET_NAME'],
|
||||
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"],
|
||||
)
|
||||
|
||||
|
||||
def get_contact_list_location(service_id, contact_list_id):
|
||||
return (
|
||||
current_app.config['CONTACT_LIST_BUCKET_NAME'],
|
||||
FILE_LOCATION_STRUCTURE.format(service_id, contact_list_id),
|
||||
)
|
||||
|
||||
|
||||
def get_job_and_metadata_from_s3(service_id, job_id):
|
||||
try:
|
||||
obj = get_s3_object(*get_job_location(service_id, job_id))
|
||||
except botocore.exceptions.ClientError:
|
||||
obj = get_s3_object(*get_old_job_location(service_id, job_id))
|
||||
|
||||
return obj.get()["Body"].read().decode("utf-8"), obj.get()["Metadata"]
|
||||
obj = get_s3_object(*get_job_location(service_id, job_id))
|
||||
return obj.get()['Body'].read().decode('utf-8'), obj.get()['Metadata']
|
||||
|
||||
|
||||
def get_job_from_s3(service_id, job_id):
|
||||
"""
|
||||
If and only if we hit a throttling exception of some kind, we want to try
|
||||
exponential backoff. However, if we are getting NoSuchKey or something
|
||||
that indicates things are permanently broken, we want to give up right away
|
||||
to save time.
|
||||
"""
|
||||
# We have to make sure the retries don't take up to much time, because
|
||||
# we might be retrieving dozens of jobs. So max time is:
|
||||
# 0.2 + 0.4 + 0.8 + 1.6 = 3.0 seconds
|
||||
retries = 0
|
||||
max_retries = 4
|
||||
backoff_factor = 0.2
|
||||
|
||||
if not file_exists(
|
||||
FILE_LOCATION_STRUCTURE.format(service_id, job_id)
|
||||
) and not file_exists(NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id)):
|
||||
current_app.logger.error(
|
||||
f"This file with service_id {service_id} and job_id {job_id} does not exist"
|
||||
)
|
||||
return None
|
||||
|
||||
while retries < max_retries:
|
||||
|
||||
try:
|
||||
# TODO
|
||||
# for transition on optimizing the s3 partition, we have
|
||||
# to check for the file location using the new way and the
|
||||
# old way. After this has been on production for a few weeks
|
||||
# we should remove the check for the old way.
|
||||
try:
|
||||
obj = get_s3_object(*get_job_location(service_id, job_id))
|
||||
return obj.get()["Body"].read().decode("utf-8")
|
||||
except botocore.exceptions.ClientError:
|
||||
obj = get_s3_object(*get_old_job_location(service_id, job_id))
|
||||
return obj.get()["Body"].read().decode("utf-8")
|
||||
except botocore.exceptions.ClientError as e:
|
||||
if e.response["Error"]["Code"] in [
|
||||
"Throttling",
|
||||
"RequestTimeout",
|
||||
"SlowDown",
|
||||
]:
|
||||
current_app.logger.exception(
|
||||
f"Retrying job fetch service_id {service_id} job_id {job_id} retry_count={retries}",
|
||||
)
|
||||
retries += 1
|
||||
sleep_time = backoff_factor * (2**retries) # Exponential backoff
|
||||
time.sleep(sleep_time)
|
||||
continue
|
||||
else:
|
||||
# Typically this is "NoSuchKey"
|
||||
current_app.logger.exception(
|
||||
f"Failed to get job with service_id {service_id} job_id {job_id}",
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
current_app.logger.exception(
|
||||
f"Failed to get job with service_id {service_id} job_id {job_id}retry_count={retries}",
|
||||
)
|
||||
return None
|
||||
|
||||
current_app.logger.error(
|
||||
f"Never retrieved job with service_id {service_id} job_id {job_id}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def extract_phones(job, service_id, job_id):
|
||||
job_csv_data = StringIO(job)
|
||||
csv_reader = csv.reader(job_csv_data)
|
||||
first_row = next(csv_reader)
|
||||
|
||||
phone_index = 0
|
||||
for i, item in enumerate(first_row):
|
||||
if item.lower().lstrip("\ufeff") == "phone number":
|
||||
phone_index = i
|
||||
break
|
||||
|
||||
phones = {}
|
||||
job_row = 0
|
||||
for row in csv_reader:
|
||||
|
||||
if phone_index >= len(row):
|
||||
phones[job_row] = "Unavailable"
|
||||
current_app.logger.error(
|
||||
f"Corrupt csv file, missing columns or\
|
||||
possibly a byte order mark in the file, \
|
||||
row: {row} service_id {service_id} job_id {job_id}",
|
||||
)
|
||||
# If the file is corrupt, stop trying to process it.
|
||||
return phones
|
||||
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):
|
||||
if isinstance(job, dict):
|
||||
job = job[0]
|
||||
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):
|
||||
job = get_job_cache(job_id)
|
||||
if job is None:
|
||||
current_app.logger.debug(f"job {job_id} was not in the cache")
|
||||
job = get_job_from_s3(service_id, job_id)
|
||||
# Even if it is None, put it here to avoid KeyErrors
|
||||
set_job_cache(job_id, job)
|
||||
else:
|
||||
# skip expiration date from cache, we don't need it here
|
||||
job = job[0]
|
||||
|
||||
if job is None:
|
||||
current_app.logger.error(
|
||||
f"Couldnt find phone for job with service_id {service_id} job_id {job_id} because job is missing"
|
||||
)
|
||||
return "Unavailable"
|
||||
|
||||
phones = extract_phones(job, service_id, job_id)
|
||||
set_job_cache(f"{job_id}_phones", phones)
|
||||
|
||||
# If we can find the quick dictionary, use it
|
||||
phone_to_return = phones[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"
|
||||
|
||||
|
||||
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 = get_job_cache(job_id)
|
||||
if job is None:
|
||||
current_app.logger.debug(f"job {job_id} was not in the cache")
|
||||
job = get_job_from_s3(service_id, job_id)
|
||||
# Even if it is None, put it here to avoid KeyErrors
|
||||
set_job_cache(job_id, job)
|
||||
else:
|
||||
# skip expiration date from cache, we don't need it here
|
||||
job = job[0]
|
||||
# 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 personalisation for job_id {job_id} row number {job_row_number} because job is missing"
|
||||
)
|
||||
return {}
|
||||
|
||||
set_job_cache(f"{job_id}_personalisation", extract_personalisation(job))
|
||||
|
||||
return get_job_cache(f"{job_id}_personalisation")[0].get(job_row_number)
|
||||
obj = get_s3_object(*get_job_location(service_id, job_id))
|
||||
return obj.get()['Body'].read().decode('utf-8')
|
||||
|
||||
|
||||
def get_job_metadata_from_s3(service_id, job_id):
|
||||
current_app.logger.debug(
|
||||
f"#notify-debug-s3-partitioning CALLING GET_JOB_METADATA with {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_s3_object(bucket_name, object_key, access_key, secret_key, region):
|
||||
obj = get_s3_object(bucket_name, object_key, access_key, secret_key, region)
|
||||
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):
|
||||
obj = get_s3_object(bucket_name, object_key)
|
||||
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):
|
||||
s3_client = client('s3', current_app.config['AWS_REGION'])
|
||||
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,46 +1,41 @@
|
||||
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#",
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"description": "POST annual billing schema",
|
||||
"type": "object",
|
||||
"title": "Create",
|
||||
"properties": {
|
||||
"free_sms_fragment_limit": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["free_sms_fragment_limit"],
|
||||
"required": ["free_sms_fragment_limit"]
|
||||
}
|
||||
|
||||
|
||||
def serialize_ft_billing_remove_emails(rows):
|
||||
return [
|
||||
{
|
||||
"month": (datetime.strftime(row.month, "%B")),
|
||||
"notification_type": row.notification_type,
|
||||
"chargeable_units": row.chargeable_units,
|
||||
"notifications_sent": row.notifications_sent,
|
||||
"rate": float(row.rate),
|
||||
"cost": float(row.cost),
|
||||
"free_allowance_used": row.free_allowance_used,
|
||||
"charged_units": row.charged_units,
|
||||
def serialize_ft_billing_remove_emails(data):
|
||||
results = []
|
||||
billed_notifications = [x for x in data if x.notification_type != 'email']
|
||||
for notification in billed_notifications:
|
||||
json_result = {
|
||||
"month": (datetime.strftime(notification.month, "%B")),
|
||||
"notification_type": notification.notification_type,
|
||||
"billing_units": notification.billable_units,
|
||||
"rate": float(notification.rate),
|
||||
"postage": notification.postage,
|
||||
}
|
||||
for row in rows
|
||||
if row.notification_type != NotificationType.EMAIL
|
||||
]
|
||||
results.append(json_result)
|
||||
return results
|
||||
|
||||
|
||||
def serialize_ft_billing_yearly_totals(rows):
|
||||
return [
|
||||
{
|
||||
"notification_type": row.notification_type,
|
||||
"chargeable_units": row.chargeable_units,
|
||||
"notifications_sent": row.notifications_sent,
|
||||
"rate": float(row.rate),
|
||||
"cost": float(row.cost),
|
||||
"free_allowance_used": row.free_allowance_used,
|
||||
"charged_units": row.charged_units,
|
||||
def serialize_ft_billing_yearly_totals(data):
|
||||
yearly_totals = []
|
||||
for total in data:
|
||||
json_result = {
|
||||
"notification_type": total.notification_type,
|
||||
"billing_units": total.billable_units,
|
||||
"rate": float(total.rate),
|
||||
"letter_total": float(total.billable_units * total.rate) if total.notification_type == 'letter' else 0
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
yearly_totals.append(json_result)
|
||||
|
||||
return yearly_totals
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from app import db
|
||||
from app.billing.billing_schemas import (
|
||||
create_or_update_free_sms_fragment_limit_schema,
|
||||
serialize_ft_billing_remove_emails,
|
||||
@@ -12,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,
|
||||
@@ -22,84 +21,88 @@ 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('/ft-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('/ft-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 = db.session.get(Service, service_id)
|
||||
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
|
||||
)
|
||||
|
||||
60
app/broadcast_message/broadcast_message_schema.py
Normal file
60
app/broadcast_message/broadcast_message_schema.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from app.models import BroadcastStatusType
|
||||
from app.schema_validation.definitions import uuid
|
||||
|
||||
create_broadcast_message_schema = {
|
||||
'$schema': 'http://json-schema.org/draft-04/schema#',
|
||||
'description': 'POST create broadcast_message schema',
|
||||
'type': 'object',
|
||||
'title': 'Create broadcast_message',
|
||||
'properties': {
|
||||
'template_id': uuid,
|
||||
'service_id': uuid,
|
||||
'created_by': uuid,
|
||||
'personalisation': {'type': 'object'},
|
||||
'starts_at': {'type': 'string', 'format': 'datetime'},
|
||||
'finishes_at': {'type': 'string', 'format': 'datetime'},
|
||||
'areas': {'type': 'object'},
|
||||
'content': {'type': 'string', 'minLength': 1},
|
||||
'reference': {'type': 'string', 'minLength': 1, 'maxLength': 255},
|
||||
},
|
||||
'required': ['service_id', 'created_by'],
|
||||
'allOf': [
|
||||
{'oneOf': [
|
||||
{'required': ['template_id']},
|
||||
{'required': ['content']},
|
||||
]},
|
||||
{'oneOf': [
|
||||
{'required': ['template_id']},
|
||||
{'required': ['reference']},
|
||||
]},
|
||||
],
|
||||
'additionalProperties': False
|
||||
}
|
||||
|
||||
update_broadcast_message_schema = {
|
||||
'$schema': 'http://json-schema.org/draft-04/schema#',
|
||||
'description': 'POST update broadcast_message schema',
|
||||
'type': 'object',
|
||||
'title': 'Update broadcast_message',
|
||||
'properties': {
|
||||
'personalisation': {'type': 'object'},
|
||||
'starts_at': {'type': 'string', 'format': 'datetime'},
|
||||
'finishes_at': {'type': 'string', 'format': 'datetime'},
|
||||
'areas': {'type': 'object'},
|
||||
},
|
||||
'required': [],
|
||||
'additionalProperties': False
|
||||
}
|
||||
|
||||
update_broadcast_message_status_schema = {
|
||||
'$schema': 'http://json-schema.org/draft-04/schema#',
|
||||
'description': 'POST update broadcast_message status schema',
|
||||
'type': 'object',
|
||||
'title': 'Update broadcast_message',
|
||||
'properties': {
|
||||
'status': {'type': 'string', 'enum': BroadcastStatusType.STATUSES},
|
||||
'created_by': uuid,
|
||||
},
|
||||
'required': ['status', 'created_by'],
|
||||
'additionalProperties': False
|
||||
}
|
||||
165
app/broadcast_message/rest.py
Normal file
165
app/broadcast_message/rest.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import iso8601
|
||||
from flask import Blueprint, jsonify, request
|
||||
from notifications_utils.template import BroadcastMessageTemplate
|
||||
|
||||
from app.broadcast_message import utils as broadcast_utils
|
||||
from app.broadcast_message.broadcast_message_schema import (
|
||||
create_broadcast_message_schema,
|
||||
update_broadcast_message_schema,
|
||||
update_broadcast_message_status_schema,
|
||||
)
|
||||
from app.dao.broadcast_message_dao import (
|
||||
dao_get_broadcast_message_by_id_and_service_id,
|
||||
dao_get_broadcast_messages_for_service,
|
||||
)
|
||||
from app.dao.dao_utils import dao_save_object
|
||||
from app.dao.services_dao import dao_fetch_service_by_id
|
||||
from app.dao.templates_dao import dao_get_template_by_id_and_service_id
|
||||
from app.dao.users_dao import get_user_by_id
|
||||
from app.errors import InvalidRequest, register_errors
|
||||
from app.models import BroadcastMessage, BroadcastStatusType
|
||||
from app.schema_validation import validate
|
||||
|
||||
broadcast_message_blueprint = Blueprint(
|
||||
'broadcast_message',
|
||||
__name__,
|
||||
url_prefix='/service/<uuid:service_id>/broadcast-message'
|
||||
)
|
||||
register_errors(broadcast_message_blueprint)
|
||||
|
||||
|
||||
def _parse_nullable_datetime(dt):
|
||||
if dt:
|
||||
return iso8601.parse_date(dt).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
@broadcast_message_blueprint.route('', methods=['GET'])
|
||||
def get_broadcast_messages_for_service(service_id):
|
||||
# TODO: should this return template content/data in some way? or can we rely on them being cached admin side.
|
||||
# we might need stuff like template name for showing on the dashboard.
|
||||
# TODO: should this paginate or filter on dates or anything?
|
||||
broadcast_messages = [o.serialize() for o in dao_get_broadcast_messages_for_service(service_id)]
|
||||
return jsonify(broadcast_messages=broadcast_messages)
|
||||
|
||||
|
||||
@broadcast_message_blueprint.route('/<uuid:broadcast_message_id>', methods=['GET'])
|
||||
def get_broadcast_message(service_id, broadcast_message_id):
|
||||
return jsonify(dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id).serialize())
|
||||
|
||||
|
||||
@broadcast_message_blueprint.route('', methods=['POST'])
|
||||
def create_broadcast_message(service_id):
|
||||
data = request.get_json()
|
||||
|
||||
validate(data, create_broadcast_message_schema)
|
||||
service = dao_fetch_service_by_id(data['service_id'])
|
||||
user = get_user_by_id(data['created_by'])
|
||||
personalisation = data.get('personalisation', {})
|
||||
template_id = data.get('template_id')
|
||||
|
||||
if template_id:
|
||||
template = dao_get_template_by_id_and_service_id(
|
||||
template_id, data['service_id']
|
||||
)
|
||||
content = str(template._as_utils_template_with_personalisation(
|
||||
personalisation
|
||||
))
|
||||
reference = None
|
||||
else:
|
||||
temporary_template = BroadcastMessageTemplate.from_content(data['content'])
|
||||
if temporary_template.content_too_long:
|
||||
raise InvalidRequest(
|
||||
(
|
||||
f'Content must be '
|
||||
f'{temporary_template.max_content_count:,.0f} '
|
||||
f'characters or fewer'
|
||||
) + (
|
||||
' (because it could not be GSM7 encoded)'
|
||||
if temporary_template.non_gsm_characters else ''
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
template = None
|
||||
content = str(temporary_template)
|
||||
reference = data['reference']
|
||||
|
||||
broadcast_message = BroadcastMessage(
|
||||
service_id=service.id,
|
||||
template_id=template_id,
|
||||
template_version=template.version if template else None,
|
||||
personalisation=personalisation,
|
||||
areas=data.get("areas", {}),
|
||||
status=BroadcastStatusType.DRAFT,
|
||||
starts_at=_parse_nullable_datetime(data.get('starts_at')),
|
||||
finishes_at=_parse_nullable_datetime(data.get('finishes_at')),
|
||||
created_by_id=user.id,
|
||||
content=content,
|
||||
reference=reference,
|
||||
stubbed=service.restricted
|
||||
)
|
||||
|
||||
dao_save_object(broadcast_message)
|
||||
|
||||
return jsonify(broadcast_message.serialize()), 201
|
||||
|
||||
|
||||
@broadcast_message_blueprint.route('/<uuid:broadcast_message_id>', methods=['POST'])
|
||||
def update_broadcast_message(service_id, broadcast_message_id):
|
||||
data = request.get_json()
|
||||
validate(data, update_broadcast_message_schema)
|
||||
|
||||
broadcast_message = dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id)
|
||||
|
||||
if broadcast_message.status not in BroadcastStatusType.PRE_BROADCAST_STATUSES:
|
||||
raise InvalidRequest(
|
||||
f'Cannot update broadcast_message {broadcast_message.id} while it has status {broadcast_message.status}',
|
||||
status_code=400
|
||||
)
|
||||
|
||||
areas = data.get("areas", {})
|
||||
|
||||
if ('ids' in areas and 'simple_polygons' not in areas) or ('ids' not in areas and 'simple_polygons' in areas):
|
||||
raise InvalidRequest(
|
||||
f'Cannot update broadcast_message {broadcast_message.id}, area IDs or polygons are missing.',
|
||||
status_code=400
|
||||
)
|
||||
|
||||
if 'personalisation' in data:
|
||||
broadcast_message.personalisation = data['personalisation']
|
||||
if 'starts_at' in data:
|
||||
broadcast_message.starts_at = _parse_nullable_datetime(data['starts_at'])
|
||||
if 'finishes_at' in data:
|
||||
broadcast_message.finishes_at = _parse_nullable_datetime(data['finishes_at'])
|
||||
if 'ids' in areas and 'simple_polygons' in areas:
|
||||
broadcast_message.areas = areas
|
||||
|
||||
dao_save_object(broadcast_message)
|
||||
|
||||
return jsonify(broadcast_message.serialize()), 200
|
||||
|
||||
|
||||
@broadcast_message_blueprint.route('/<uuid:broadcast_message_id>/status', methods=['POST'])
|
||||
def update_broadcast_message_status(service_id, broadcast_message_id):
|
||||
data = request.get_json()
|
||||
|
||||
validate(data, update_broadcast_message_status_schema)
|
||||
broadcast_message = dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id)
|
||||
|
||||
if not broadcast_message.service.active:
|
||||
raise InvalidRequest("Updating broadcast message is not allowed: service is inactive ", 403)
|
||||
|
||||
new_status = data['status']
|
||||
updating_user = get_user_by_id(data['created_by'])
|
||||
|
||||
if updating_user not in broadcast_message.service.users:
|
||||
# we allow platform admins to cancel broadcasts, and we don't check user if request was done via API
|
||||
if not (new_status == BroadcastStatusType.CANCELLED and updating_user.platform_admin):
|
||||
raise InvalidRequest(
|
||||
f'User {updating_user.id} cannot update broadcast_message {broadcast_message.id} from other service',
|
||||
status_code=400
|
||||
)
|
||||
|
||||
broadcast_utils.update_broadcast_message_status(broadcast_message, new_status, updating_user)
|
||||
|
||||
return jsonify(broadcast_message.serialize()), 200
|
||||
37
app/broadcast_message/translators.py
Normal file
37
app/broadcast_message/translators.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def cap_xml_to_dict(cap_xml):
|
||||
# This function assumes that it’s being passed valid CAP XML
|
||||
cap = BeautifulSoup(cap_xml, "xml")
|
||||
return {
|
||||
"msgType": cap.alert.msgType.text,
|
||||
"reference": cap.alert.identifier.text,
|
||||
"references": (
|
||||
# references to previous events belonging to the same alert
|
||||
cap.alert.references.text if cap.alert.references else None
|
||||
),
|
||||
"cap_event": cap.alert.info.event.text,
|
||||
"category": cap.alert.info.category.text,
|
||||
"expires": cap.alert.info.expires.text,
|
||||
"content": cap.alert.info.description.text,
|
||||
"areas": [
|
||||
{
|
||||
"name": area.areaDesc.text,
|
||||
"polygons": [
|
||||
cap_xml_polygon_to_list(polygon.text)
|
||||
for polygon in area.find_all('polygon')
|
||||
]
|
||||
}
|
||||
for area in cap.alert.info.find_all('area')
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def cap_xml_polygon_to_list(polygon_string):
|
||||
return [
|
||||
[
|
||||
float(coordinate) for coordinate in pair.split(',')
|
||||
]
|
||||
for pair in polygon_string.strip().split(' ')
|
||||
]
|
||||
140
app/broadcast_message/utils.py
Normal file
140
app/broadcast_message/utils.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import inspect
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.clients.zendesk.zendesk_client import (
|
||||
NotifySupportTicket,
|
||||
)
|
||||
|
||||
from app import zendesk_client
|
||||
from app.celery.broadcast_message_tasks import send_broadcast_event
|
||||
from app.config import QueueNames
|
||||
from app.dao.dao_utils import dao_save_object
|
||||
from app.errors import InvalidRequest
|
||||
from app.models import (
|
||||
BroadcastEvent,
|
||||
BroadcastEventMessageType,
|
||||
BroadcastStatusType,
|
||||
)
|
||||
|
||||
|
||||
def update_broadcast_message_status(broadcast_message, new_status, updating_user=None, api_key_id=None):
|
||||
_validate_broadcast_update(broadcast_message, new_status, updating_user)
|
||||
|
||||
if new_status == BroadcastStatusType.BROADCASTING:
|
||||
broadcast_message.approved_at = datetime.utcnow()
|
||||
broadcast_message.approved_by = updating_user
|
||||
|
||||
if new_status == BroadcastStatusType.CANCELLED:
|
||||
broadcast_message.cancelled_at = datetime.utcnow()
|
||||
broadcast_message.cancelled_by = updating_user
|
||||
broadcast_message.cancelled_by_api_key_id = api_key_id
|
||||
|
||||
current_app.logger.info(
|
||||
f'broadcast_message {broadcast_message.id} moving from {broadcast_message.status} to {new_status}'
|
||||
)
|
||||
broadcast_message.status = new_status
|
||||
|
||||
dao_save_object(broadcast_message)
|
||||
_create_p1_zendesk_alert(broadcast_message)
|
||||
|
||||
if new_status in {BroadcastStatusType.BROADCASTING, BroadcastStatusType.CANCELLED}:
|
||||
_create_broadcast_event(broadcast_message)
|
||||
|
||||
|
||||
def _validate_broadcast_update(broadcast_message, new_status, updating_user):
|
||||
if new_status not in BroadcastStatusType.ALLOWED_STATUS_TRANSITIONS[broadcast_message.status]:
|
||||
raise InvalidRequest(
|
||||
f'Cannot move broadcast_message {broadcast_message.id} from {broadcast_message.status} to {new_status}',
|
||||
status_code=400
|
||||
)
|
||||
|
||||
if new_status == BroadcastStatusType.BROADCASTING:
|
||||
# training mode services can approve their own broadcasts
|
||||
if updating_user == broadcast_message.created_by and not broadcast_message.service.restricted:
|
||||
raise InvalidRequest(
|
||||
f'User {updating_user.id} cannot approve their own broadcast_message {broadcast_message.id}',
|
||||
status_code=400
|
||||
)
|
||||
elif len(broadcast_message.areas['simple_polygons']) == 0:
|
||||
raise InvalidRequest(
|
||||
f'broadcast_message {broadcast_message.id} has no selected areas and so cannot be broadcasted.',
|
||||
status_code=400
|
||||
)
|
||||
|
||||
|
||||
def _create_p1_zendesk_alert(broadcast_message):
|
||||
if current_app.config['NOTIFY_ENVIRONMENT'] != 'live':
|
||||
return
|
||||
|
||||
if broadcast_message.status != BroadcastStatusType.BROADCASTING:
|
||||
return
|
||||
|
||||
message = inspect.cleandoc(f"""
|
||||
Broadcast Sent
|
||||
|
||||
https://www.notifications.service.gov.uk/services/{broadcast_message.service_id}/current-alerts/{broadcast_message.id}
|
||||
|
||||
Sent on channel {broadcast_message.service.broadcast_channel} to {broadcast_message.areas["names"]}.
|
||||
|
||||
Content starts "{broadcast_message.content[:100]}".
|
||||
|
||||
Follow the runbook to check the broadcast went out OK:
|
||||
https://docs.google.com/document/d/1J99yOlfp4nQz6et0w5oJVqi-KywtIXkxrEIyq_g2XUs/edit#heading=h.lzr9aq5b4wg
|
||||
""")
|
||||
|
||||
ticket = NotifySupportTicket(
|
||||
subject='Live broadcast sent',
|
||||
message=message,
|
||||
ticket_type=NotifySupportTicket.TYPE_INCIDENT,
|
||||
technical_ticket=True,
|
||||
org_id=current_app.config['BROADCAST_ORGANISATION_ID'],
|
||||
org_type='central',
|
||||
service_id=str(broadcast_message.service_id),
|
||||
p1=True
|
||||
)
|
||||
zendesk_client.send_ticket_to_zendesk(ticket)
|
||||
|
||||
|
||||
def _create_broadcast_event(broadcast_message):
|
||||
"""
|
||||
If the service is live and the broadcast message is not stubbed, creates a broadcast event, stores it in the
|
||||
database, and triggers the task to send the CAP XML off.
|
||||
"""
|
||||
service = broadcast_message.service
|
||||
|
||||
if not broadcast_message.stubbed and not service.restricted:
|
||||
msg_types = {
|
||||
BroadcastStatusType.BROADCASTING: BroadcastEventMessageType.ALERT,
|
||||
BroadcastStatusType.CANCELLED: BroadcastEventMessageType.CANCEL,
|
||||
}
|
||||
|
||||
event = BroadcastEvent(
|
||||
service=service,
|
||||
broadcast_message=broadcast_message,
|
||||
message_type=msg_types[broadcast_message.status],
|
||||
transmitted_content={"body": broadcast_message.content},
|
||||
transmitted_areas=broadcast_message.areas,
|
||||
# TODO: Probably move this somewhere more standalone too and imply that it shouldn't change. Should it
|
||||
# include a service based identifier too? eg "flood-warnings@notifications.service.gov.uk" or similar
|
||||
transmitted_sender='notifications.service.gov.uk',
|
||||
|
||||
# TODO: Should this be set to now? Or the original starts_at?
|
||||
transmitted_starts_at=broadcast_message.starts_at,
|
||||
transmitted_finishes_at=broadcast_message.finishes_at,
|
||||
)
|
||||
|
||||
dao_save_object(event)
|
||||
|
||||
send_broadcast_event.apply_async(
|
||||
kwargs={'broadcast_event_id': str(event.id)},
|
||||
queue=QueueNames.BROADCASTS
|
||||
)
|
||||
elif broadcast_message.stubbed != service.restricted:
|
||||
# It's possible for a service to create a broadcast in trial mode, and then approve it after the
|
||||
# service is live (or vice versa). We don't think it's safe to send such broadcasts, as the service
|
||||
# has changed since they were created. Log an error instead.
|
||||
current_app.logger.error(
|
||||
f'Broadcast event not created. Stubbed status of broadcast message was {broadcast_message.stubbed}'
|
||||
f' but service was {"in trial mode" if service.restricted else "live"}'
|
||||
)
|
||||
230
app/celery/broadcast_message_tasks.py
Normal file
230
app/celery/broadcast_message_tasks.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from app import cbc_proxy_client, notify_celery
|
||||
from app.clients.cbc_proxy import CBCProxyRetryableException
|
||||
from app.config import QueueNames, TaskNames
|
||||
from app.dao.broadcast_message_dao import (
|
||||
create_broadcast_provider_message,
|
||||
dao_get_broadcast_event_by_id,
|
||||
update_broadcast_provider_message_status,
|
||||
)
|
||||
from app.models import (
|
||||
BroadcastEventMessageType,
|
||||
BroadcastProvider,
|
||||
BroadcastProviderMessageStatus,
|
||||
)
|
||||
from app.utils import format_sequential_number
|
||||
|
||||
|
||||
class BroadcastIntegrityError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_retry_delay(retry_count):
|
||||
"""
|
||||
Given a count of retries so far, return a delay for the next one.
|
||||
`retry_count` should be 0 the first time a task fails.
|
||||
"""
|
||||
# TODO: replace with celery's built in exponential backoff
|
||||
|
||||
# 2 to the power of x. 1, 2, 4, 8, 16, 32, ...
|
||||
delay = 2**retry_count
|
||||
# never wait longer than 4 minutes
|
||||
return min(delay, 240)
|
||||
|
||||
|
||||
def check_event_is_authorised_to_be_sent(broadcast_event, provider):
|
||||
if not broadcast_event.service.active:
|
||||
raise BroadcastIntegrityError(
|
||||
f'Cannot send broadcast_event {broadcast_event.id} ' +
|
||||
f'to provider {provider}: the service is suspended'
|
||||
)
|
||||
|
||||
if broadcast_event.service.restricted:
|
||||
raise BroadcastIntegrityError(
|
||||
f'Cannot send broadcast_event {broadcast_event.id} ' +
|
||||
f'to provider {provider}: the service is not live'
|
||||
)
|
||||
|
||||
if broadcast_event.broadcast_message.stubbed:
|
||||
raise BroadcastIntegrityError(
|
||||
f'Cannot send broadcast_event {broadcast_event.id} ' +
|
||||
f'to provider {provider}: the broadcast message is stubbed'
|
||||
)
|
||||
|
||||
|
||||
def check_event_makes_sense_in_sequence(broadcast_event, provider):
|
||||
"""
|
||||
If any previous event hasn't sent yet for that provider, then we shouldn't send the current event. Instead, fail and
|
||||
raise a zendesk ticket - so that a notify team member can assess the state of the previous messages, and if
|
||||
necessary, can replay the `send_broadcast_provider_message` task if the previous message has now been sent.
|
||||
|
||||
Note: This is called before the new broadcast_provider_message is created.
|
||||
|
||||
# Help, I've come across this code following a pagerduty alert, what should I do?
|
||||
|
||||
1. Find the failing broadcast_provider_message associated with the previous event that caused this to trip.
|
||||
2. If that provider message is still failing to send, fix the issue causing that. The task to send that previous
|
||||
message might still be retrying in the background - look for logs related to that task.
|
||||
3. If that provider message has sent succesfully, you might need to send this task off depending on context. This
|
||||
might not always be true though, for example, it may not be necessary to send a cancel if the original alert has
|
||||
already expired.
|
||||
4. If you need to re-send this task off again, you'll need to run the following command on paas:
|
||||
`send_broadcast_provider_message.apply_async(args=(broadcast_event_id, provider), queue=QueueNames.BROADCASTS)`
|
||||
"""
|
||||
current_provider_message = broadcast_event.get_provider_message(provider)
|
||||
# if this is the first time a task is being executed, it won't have a provider message yet
|
||||
if current_provider_message and current_provider_message.status != BroadcastProviderMessageStatus.SENDING:
|
||||
raise BroadcastIntegrityError(
|
||||
f'Cannot send broadcast_event {broadcast_event.id} ' +
|
||||
f'to provider {provider}: ' +
|
||||
f'It is in status {current_provider_message.status}'
|
||||
)
|
||||
|
||||
if broadcast_event.transmitted_finishes_at < datetime.utcnow():
|
||||
raise BroadcastIntegrityError(
|
||||
f'Cannot send broadcast_event {broadcast_event.id} ' +
|
||||
f'to provider {provider}: ' +
|
||||
f'The expiry time of {broadcast_event.transmitted_finishes_at} has already passed'
|
||||
)
|
||||
|
||||
# get events sorted from earliest to latest
|
||||
events = sorted(broadcast_event.broadcast_message.events, key=lambda x: x.sent_at)
|
||||
|
||||
for prev_event in events:
|
||||
if prev_event.id != broadcast_event.id and prev_event.sent_at < broadcast_event.sent_at:
|
||||
# get the record from when that event was sent to the same provider
|
||||
prev_provider_message = prev_event.get_provider_message(provider)
|
||||
|
||||
# the previous message hasn't even got round to running `send_broadcast_provider_message` yet.
|
||||
if not prev_provider_message:
|
||||
raise BroadcastIntegrityError(
|
||||
f'Cannot send {broadcast_event.id}. Previous event {prev_event.id} ' +
|
||||
f'(type {prev_event.message_type}) has no provider_message for provider {provider} yet.\n' +
|
||||
'You must ensure that the other event sends succesfully, then manually kick off this event ' +
|
||||
'again by re-running send_broadcast_provider_message for this event and provider.'
|
||||
)
|
||||
|
||||
# if there's a previous message that has started but not finished sending (whether it fatally errored or is
|
||||
# currently retrying)
|
||||
if prev_provider_message.status != BroadcastProviderMessageStatus.ACK:
|
||||
raise BroadcastIntegrityError(
|
||||
f'Cannot send {broadcast_event.id}. Previous event {prev_event.id} ' +
|
||||
f'(type {prev_event.message_type}) has not finished sending to provider {provider} yet.\n' +
|
||||
f'It is currently in status "{prev_provider_message.status}".\n' +
|
||||
'You must ensure that the other event sends succesfully, then manually kick off this event ' +
|
||||
'again by re-running send_broadcast_provider_message for this event and provider.'
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name="send-broadcast-event")
|
||||
def send_broadcast_event(broadcast_event_id):
|
||||
broadcast_event = dao_get_broadcast_event_by_id(broadcast_event_id)
|
||||
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.PUBLISH_GOVUK_ALERTS,
|
||||
queue=QueueNames.GOVUK_ALERTS
|
||||
)
|
||||
|
||||
for provider in broadcast_event.service.get_available_broadcast_providers():
|
||||
send_broadcast_provider_message.apply_async(
|
||||
kwargs={'broadcast_event_id': broadcast_event_id, 'provider': provider},
|
||||
queue=QueueNames.BROADCASTS
|
||||
)
|
||||
|
||||
|
||||
# max_retries=None: retry forever
|
||||
@notify_celery.task(bind=True, name="send-broadcast-provider-message", max_retries=None)
|
||||
def send_broadcast_provider_message(self, broadcast_event_id, provider):
|
||||
if not current_app.config['CBC_PROXY_ENABLED']:
|
||||
current_app.logger.info(
|
||||
"CBC Proxy disabled, not sending broadcast_provider_message for "
|
||||
f"broadcast_event_id {broadcast_event_id} with provider {provider}"
|
||||
)
|
||||
return
|
||||
|
||||
broadcast_event = dao_get_broadcast_event_by_id(broadcast_event_id)
|
||||
|
||||
check_event_is_authorised_to_be_sent(broadcast_event, provider)
|
||||
check_event_makes_sense_in_sequence(broadcast_event, provider)
|
||||
|
||||
# the broadcast_provider_message may already exist if we retried previously
|
||||
broadcast_provider_message = broadcast_event.get_provider_message(provider)
|
||||
if broadcast_provider_message is None:
|
||||
broadcast_provider_message = create_broadcast_provider_message(broadcast_event, provider)
|
||||
|
||||
formatted_message_number = None
|
||||
if provider == BroadcastProvider.VODAFONE:
|
||||
formatted_message_number = format_sequential_number(broadcast_provider_message.message_number)
|
||||
|
||||
current_app.logger.info(
|
||||
f'Invoking cbc proxy to send broadcast_provider_message with ID of {broadcast_provider_message.id} '
|
||||
f'and broadcast_event ID of {broadcast_event_id} '
|
||||
f'msgType {broadcast_event.message_type}'
|
||||
)
|
||||
|
||||
areas = [
|
||||
{"polygon": polygon}
|
||||
for polygon in broadcast_event.transmitted_areas["simple_polygons"]
|
||||
]
|
||||
|
||||
cbc_proxy_provider_client = cbc_proxy_client.get_proxy(provider)
|
||||
|
||||
try:
|
||||
if broadcast_event.message_type == BroadcastEventMessageType.ALERT:
|
||||
cbc_proxy_provider_client.create_and_send_broadcast(
|
||||
identifier=str(broadcast_provider_message.id),
|
||||
message_number=formatted_message_number,
|
||||
headline="GOV.UK Notify Broadcast",
|
||||
description=broadcast_event.transmitted_content['body'],
|
||||
areas=areas,
|
||||
sent=broadcast_event.sent_at_as_cap_datetime_string,
|
||||
expires=broadcast_event.transmitted_finishes_at_as_cap_datetime_string,
|
||||
channel=broadcast_event.service.broadcast_channel
|
||||
)
|
||||
elif broadcast_event.message_type == BroadcastEventMessageType.UPDATE:
|
||||
cbc_proxy_provider_client.update_and_send_broadcast(
|
||||
identifier=str(broadcast_provider_message.id),
|
||||
message_number=formatted_message_number,
|
||||
headline="GOV.UK Notify Broadcast",
|
||||
description=broadcast_event.transmitted_content['body'],
|
||||
areas=areas,
|
||||
previous_provider_messages=broadcast_event.get_earlier_provider_messages(provider),
|
||||
sent=broadcast_event.sent_at_as_cap_datetime_string,
|
||||
expires=broadcast_event.transmitted_finishes_at_as_cap_datetime_string,
|
||||
# We think an alert update should always go out on the same channel that created the alert
|
||||
# We recognise there is a small risk with this code here that if the services channel was
|
||||
# changed between an alert being sent out and then updated, then something might go wrong
|
||||
# but we are relying on service channels changing almost never, and not mid incident
|
||||
# We may consider in the future, changing this such that we store the channel a broadcast was
|
||||
# sent on on the broadcast message itself and pick the value from there instead of the service
|
||||
channel=broadcast_event.service.broadcast_channel
|
||||
)
|
||||
elif broadcast_event.message_type == BroadcastEventMessageType.CANCEL:
|
||||
cbc_proxy_provider_client.cancel_broadcast(
|
||||
identifier=str(broadcast_provider_message.id),
|
||||
message_number=formatted_message_number,
|
||||
previous_provider_messages=broadcast_event.get_earlier_provider_messages(provider),
|
||||
sent=broadcast_event.sent_at_as_cap_datetime_string,
|
||||
)
|
||||
except CBCProxyRetryableException as exc:
|
||||
delay = get_retry_delay(self.request.retries)
|
||||
current_app.logger.exception(
|
||||
f'Retrying send_broadcast_provider_message for broadcast event {broadcast_event_id}, '
|
||||
f'provider message {broadcast_provider_message.id}, provider {provider} in {delay} seconds'
|
||||
)
|
||||
|
||||
self.retry(
|
||||
exc=exc,
|
||||
countdown=delay,
|
||||
queue=QueueNames.BROADCASTS,
|
||||
)
|
||||
|
||||
update_broadcast_provider_message_status(broadcast_provider_message, status=BroadcastProviderMessageStatus.ACK)
|
||||
|
||||
|
||||
@notify_celery.task(name='trigger-link-test')
|
||||
def trigger_link_test(provider):
|
||||
cbc_proxy_client.get_proxy(provider).send_link_test()
|
||||
570
app/celery/letters_pdf_tasks.py
Normal file
570
app/celery/letters_pdf_tasks.py
Normal file
@@ -0,0 +1,570 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import sha512
|
||||
|
||||
from botocore.exceptions import ClientError as BotoClientError
|
||||
from flask import current_app
|
||||
from notifications_utils.letter_timings import LETTER_PROCESSING_DEADLINE
|
||||
from notifications_utils.postal_address import PostalAddress
|
||||
from notifications_utils.timezones import convert_utc_to_bst
|
||||
|
||||
from app import encryption, notify_celery
|
||||
from app.aws import s3
|
||||
from app.config import QueueNames, TaskNames
|
||||
from app.cronitor import cronitor
|
||||
from app.dao.notifications_dao import (
|
||||
dao_get_letters_and_sheets_volume_by_postage,
|
||||
dao_get_letters_to_be_printed,
|
||||
dao_get_notification_by_reference,
|
||||
dao_update_notification,
|
||||
dao_update_notifications_by_reference,
|
||||
get_notification_by_id,
|
||||
update_notification_status_by_id,
|
||||
)
|
||||
from app.dao.templates_dao import dao_get_template_by_id
|
||||
from app.errors import VirusScanError
|
||||
from app.exceptions import NotificationTechnicalFailureException
|
||||
from app.letters.utils import (
|
||||
LetterPDFNotFound,
|
||||
ScanErrorType,
|
||||
find_letter_pdf_in_s3,
|
||||
generate_letter_pdf_filename,
|
||||
get_billable_units_for_letter_page_count,
|
||||
get_file_names_from_error_bucket,
|
||||
get_folder_name,
|
||||
get_reference_from_filename,
|
||||
move_error_pdf_to_scan_bucket,
|
||||
move_failed_pdf,
|
||||
move_sanitised_letter_to_test_or_live_pdf_bucket,
|
||||
move_scan_to_invalid_pdf_bucket,
|
||||
)
|
||||
from app.models import (
|
||||
INTERNATIONAL_LETTERS,
|
||||
INTERNATIONAL_POSTAGE_TYPES,
|
||||
KEY_TYPE_NORMAL,
|
||||
KEY_TYPE_TEST,
|
||||
NOTIFICATION_CREATED,
|
||||
NOTIFICATION_DELIVERED,
|
||||
NOTIFICATION_PENDING_VIRUS_CHECK,
|
||||
NOTIFICATION_TECHNICAL_FAILURE,
|
||||
NOTIFICATION_VALIDATION_FAILED,
|
||||
NOTIFICATION_VIRUS_SCAN_FAILED,
|
||||
POSTAGE_TYPES,
|
||||
RESOLVE_POSTAGE_FOR_FILE_NAME,
|
||||
Service,
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="get-pdf-for-templated-letter", max_retries=15, default_retry_delay=300)
|
||||
def get_pdf_for_templated_letter(self, notification_id):
|
||||
try:
|
||||
notification = get_notification_by_id(notification_id, _raise=True)
|
||||
letter_filename = generate_letter_pdf_filename(
|
||||
reference=notification.reference,
|
||||
created_at=notification.created_at,
|
||||
ignore_folder=notification.key_type == KEY_TYPE_TEST,
|
||||
postage=notification.postage
|
||||
)
|
||||
letter_data = {
|
||||
'letter_contact_block': notification.reply_to_text,
|
||||
'template': {
|
||||
"subject": notification.template.subject,
|
||||
"content": notification.template.content,
|
||||
"template_type": notification.template.template_type
|
||||
},
|
||||
'values': notification.personalisation,
|
||||
'logo_filename': notification.service.letter_branding and notification.service.letter_branding.filename,
|
||||
'letter_filename': letter_filename,
|
||||
"notification_id": str(notification_id),
|
||||
'key_type': notification.key_type
|
||||
}
|
||||
|
||||
encrypted_data = encryption.encrypt(letter_data)
|
||||
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.CREATE_PDF_FOR_TEMPLATED_LETTER,
|
||||
args=(encrypted_data,),
|
||||
queue=QueueNames.SANITISE_LETTERS
|
||||
)
|
||||
except Exception as e:
|
||||
try:
|
||||
current_app.logger.exception(
|
||||
f"RETRY: calling create-letter-pdf task for notification {notification_id} failed"
|
||||
)
|
||||
self.retry(exc=e, queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
message = f"RETRY FAILED: Max retries reached. " \
|
||||
f"The task create-letter-pdf failed for notification id {notification_id}. " \
|
||||
f"Notification has been updated to technical-failure"
|
||||
update_notification_status_by_id(notification_id, NOTIFICATION_TECHNICAL_FAILURE)
|
||||
raise NotificationTechnicalFailureException(message)
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="update-billable-units-for-letter", max_retries=15, default_retry_delay=300)
|
||||
def update_billable_units_for_letter(self, notification_id, page_count):
|
||||
notification = get_notification_by_id(notification_id, _raise=True)
|
||||
|
||||
billable_units = get_billable_units_for_letter_page_count(page_count)
|
||||
|
||||
if notification.key_type != KEY_TYPE_TEST:
|
||||
notification.billable_units = billable_units
|
||||
dao_update_notification(notification)
|
||||
|
||||
current_app.logger.info(
|
||||
f"Letter notification id: {notification_id} reference {notification.reference}: "
|
||||
f"billable units set to {billable_units}"
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, name="update-validation-failed-for-templated-letter", max_retries=15, default_retry_delay=300
|
||||
)
|
||||
def update_validation_failed_for_templated_letter(self, notification_id, page_count):
|
||||
notification = get_notification_by_id(notification_id, _raise=True)
|
||||
notification.status = NOTIFICATION_VALIDATION_FAILED
|
||||
dao_update_notification(notification)
|
||||
current_app.logger.info(f"Validation failed: letter is too long {page_count} for letter with id: {notification_id}")
|
||||
|
||||
|
||||
@notify_celery.task(name='collate-letter-pdfs-to-be-sent')
|
||||
@cronitor("collate-letter-pdfs-to-be-sent")
|
||||
def collate_letter_pdfs_to_be_sent():
|
||||
"""
|
||||
Finds all letters which are still waiting to be sent to DVLA for printing
|
||||
|
||||
This would usually be run at 5.50pm and collect up letters created between before 5:30pm today
|
||||
that have not yet been sent.
|
||||
If run after midnight, it will collect up letters created before 5:30pm the day before.
|
||||
"""
|
||||
print_run_date = convert_utc_to_bst(datetime.utcnow())
|
||||
if print_run_date.time() < LETTER_PROCESSING_DEADLINE:
|
||||
print_run_date = print_run_date - timedelta(days=1)
|
||||
|
||||
print_run_deadline = print_run_date.replace(
|
||||
hour=17, minute=30, second=0, microsecond=0
|
||||
)
|
||||
_get_letters_and_sheets_volumes_and_send_to_dvla(print_run_deadline)
|
||||
|
||||
for postage in POSTAGE_TYPES:
|
||||
current_app.logger.info(f"starting collate-letter-pdfs-to-be-sent processing for postage class {postage}")
|
||||
letters_to_print = get_key_and_size_of_letters_to_be_sent_to_print(print_run_deadline, postage)
|
||||
|
||||
for i, letters in enumerate(group_letters(letters_to_print)):
|
||||
filenames = [letter['Key'] for letter in letters]
|
||||
|
||||
service_id = letters[0]['ServiceId']
|
||||
organisation_id = letters[0]['OrganisationId']
|
||||
|
||||
hash = urlsafe_b64encode(sha512(''.join(filenames).encode()).digest())[:20].decode()
|
||||
# eg NOTIFY.2018-12-31.001.Wjrui5nAvObjPd-3GEL-.ZIP
|
||||
dvla_filename = 'NOTIFY.{date}.{postage}.{num:03}.{hash}.{service_id}.{organisation_id}.ZIP'.format(
|
||||
date=print_run_deadline.strftime("%Y-%m-%d"),
|
||||
postage=RESOLVE_POSTAGE_FOR_FILE_NAME[postage],
|
||||
num=i + 1,
|
||||
hash=hash,
|
||||
service_id=service_id,
|
||||
organisation_id=organisation_id
|
||||
)
|
||||
|
||||
current_app.logger.info(
|
||||
'Calling task zip-and-send-letter-pdfs for {} pdfs to upload {} with total size {:,} bytes'.format(
|
||||
len(filenames),
|
||||
dvla_filename,
|
||||
sum(letter['Size'] for letter in letters)
|
||||
)
|
||||
)
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.ZIP_AND_SEND_LETTER_PDFS,
|
||||
kwargs={
|
||||
'filenames_to_zip': filenames,
|
||||
'upload_filename': dvla_filename
|
||||
},
|
||||
queue=QueueNames.PROCESS_FTP,
|
||||
compression='zlib'
|
||||
)
|
||||
current_app.logger.info(f"finished collate-letter-pdfs-to-be-sent processing for postage class {postage}")
|
||||
|
||||
current_app.logger.info("finished collate-letter-pdfs-to-be-sent")
|
||||
|
||||
|
||||
def _get_letters_and_sheets_volumes_and_send_to_dvla(print_run_deadline):
|
||||
letters_volumes = dao_get_letters_and_sheets_volume_by_postage(print_run_deadline)
|
||||
send_letters_volume_email_to_dvla(letters_volumes, print_run_deadline.date())
|
||||
|
||||
|
||||
def send_letters_volume_email_to_dvla(letters_volumes, date):
|
||||
personalisation = {
|
||||
'total_volume': 0,
|
||||
'first_class_volume': 0,
|
||||
'second_class_volume': 0,
|
||||
'international_volume': 0,
|
||||
'total_sheets': 0,
|
||||
'first_class_sheets': 0,
|
||||
"second_class_sheets": 0,
|
||||
'international_sheets': 0,
|
||||
'date': date.strftime("%d %B %Y")
|
||||
}
|
||||
for item in letters_volumes:
|
||||
personalisation['total_volume'] += item.letters_count
|
||||
personalisation['total_sheets'] += item.sheets_count
|
||||
if f"{item.postage}_class_volume" in personalisation:
|
||||
personalisation[f"{item.postage}_class_volume"] = item.letters_count
|
||||
personalisation[f"{item.postage}_class_sheets"] = item.sheets_count
|
||||
else:
|
||||
personalisation["international_volume"] += item.letters_count
|
||||
personalisation["international_sheets"] += item.sheets_count
|
||||
|
||||
template = dao_get_template_by_id(current_app.config['LETTERS_VOLUME_EMAIL_TEMPLATE_ID'])
|
||||
recipients = current_app.config['DVLA_EMAIL_ADDRESSES']
|
||||
reply_to = template.service.get_default_reply_to_email_address()
|
||||
service = Service.query.get(current_app.config['NOTIFY_SERVICE_ID'])
|
||||
|
||||
# avoid circular imports:
|
||||
from app.notifications.process_notifications import (
|
||||
persist_notification,
|
||||
send_notification_to_queue,
|
||||
)
|
||||
for recipient in recipients:
|
||||
saved_notification = persist_notification(
|
||||
template_id=template.id,
|
||||
template_version=template.version,
|
||||
recipient=recipient,
|
||||
service=service,
|
||||
personalisation=personalisation,
|
||||
notification_type=template.template_type,
|
||||
api_key_id=None,
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
reply_to_text=reply_to
|
||||
)
|
||||
|
||||
send_notification_to_queue(saved_notification, False, queue=QueueNames.NOTIFY)
|
||||
|
||||
|
||||
def get_key_and_size_of_letters_to_be_sent_to_print(print_run_deadline, postage):
|
||||
letters_awaiting_sending = dao_get_letters_to_be_printed(print_run_deadline, postage)
|
||||
for letter in letters_awaiting_sending:
|
||||
try:
|
||||
letter_pdf = find_letter_pdf_in_s3(letter)
|
||||
yield {
|
||||
"Key": letter_pdf.key,
|
||||
"Size": letter_pdf.size,
|
||||
"ServiceId": str(letter.service_id),
|
||||
"OrganisationId": str(letter.service.organisation_id)
|
||||
}
|
||||
except (BotoClientError, LetterPDFNotFound) as e:
|
||||
current_app.logger.exception(
|
||||
f"Error getting letter from bucket for notification: {letter.id} with reference: {letter.reference}", e)
|
||||
|
||||
|
||||
def group_letters(letter_pdfs):
|
||||
"""
|
||||
Group letters in chunks of MAX_LETTER_PDF_ZIP_FILESIZE. Will add files to lists, never going over that size.
|
||||
If a single file is (somehow) larger than MAX_LETTER_PDF_ZIP_FILESIZE that'll be in a list on it's own.
|
||||
If there are no files, will just exit (rather than yielding an empty list).
|
||||
"""
|
||||
running_filesize = 0
|
||||
list_of_files = []
|
||||
service_id = None
|
||||
for letter in letter_pdfs:
|
||||
if letter['Key'].lower().endswith('.pdf'):
|
||||
if not service_id:
|
||||
service_id = letter['ServiceId']
|
||||
if (
|
||||
running_filesize + letter['Size'] > current_app.config['MAX_LETTER_PDF_ZIP_FILESIZE']
|
||||
or len(list_of_files) >= current_app.config['MAX_LETTER_PDF_COUNT_PER_ZIP']
|
||||
or letter['ServiceId'] != service_id
|
||||
):
|
||||
yield list_of_files
|
||||
running_filesize = 0
|
||||
list_of_files = []
|
||||
service_id = None
|
||||
|
||||
if not service_id:
|
||||
service_id = letter['ServiceId']
|
||||
running_filesize += letter['Size']
|
||||
list_of_files.append(letter)
|
||||
|
||||
if list_of_files:
|
||||
yield list_of_files
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name='sanitise-letter', max_retries=15, default_retry_delay=300)
|
||||
def sanitise_letter(self, filename):
|
||||
try:
|
||||
reference = get_reference_from_filename(filename)
|
||||
notification = dao_get_notification_by_reference(reference)
|
||||
|
||||
current_app.logger.info('Notification ID {} Virus scan passed: {}'.format(notification.id, filename))
|
||||
|
||||
if notification.status != NOTIFICATION_PENDING_VIRUS_CHECK:
|
||||
current_app.logger.info('Sanitise letter called for notification {} which is in {} state'.format(
|
||||
notification.id, notification.status))
|
||||
return
|
||||
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.SANITISE_LETTER,
|
||||
kwargs={
|
||||
'notification_id': str(notification.id),
|
||||
'filename': filename,
|
||||
'allow_international_letters': notification.service.has_permission(
|
||||
INTERNATIONAL_LETTERS
|
||||
),
|
||||
},
|
||||
queue=QueueNames.SANITISE_LETTERS,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
current_app.logger.exception(
|
||||
"RETRY: calling sanitise_letter task for notification {} failed".format(notification.id)
|
||||
)
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
message = "RETRY FAILED: Max retries reached. " \
|
||||
"The task sanitise_letter 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='process-sanitised-letter', max_retries=15, default_retry_delay=300)
|
||||
def process_sanitised_letter(self, sanitise_data):
|
||||
letter_details = encryption.decrypt(sanitise_data)
|
||||
|
||||
filename = letter_details['filename']
|
||||
notification_id = letter_details['notification_id']
|
||||
|
||||
current_app.logger.info('Processing sanitised letter with id {}'.format(notification_id))
|
||||
notification = get_notification_by_id(notification_id, _raise=True)
|
||||
|
||||
if notification.status != NOTIFICATION_PENDING_VIRUS_CHECK:
|
||||
current_app.logger.info(
|
||||
'process-sanitised-letter task called for notification {} which is in {} state'.format(
|
||||
notification.id, notification.status)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
original_pdf_object = s3.get_s3_object(current_app.config['LETTERS_SCAN_BUCKET_NAME'], filename)
|
||||
|
||||
if letter_details['validation_status'] == 'failed':
|
||||
current_app.logger.info('Processing invalid precompiled pdf with id {} (file {})'.format(
|
||||
notification_id, filename))
|
||||
|
||||
_move_invalid_letter_and_update_status(
|
||||
notification=notification,
|
||||
filename=filename,
|
||||
scan_pdf_object=original_pdf_object,
|
||||
message=letter_details['message'],
|
||||
invalid_pages=letter_details['invalid_pages'],
|
||||
page_count=letter_details['page_count'],
|
||||
)
|
||||
return
|
||||
|
||||
current_app.logger.info('Processing valid precompiled pdf with id {} (file {})'.format(
|
||||
notification_id, filename))
|
||||
|
||||
billable_units = get_billable_units_for_letter_page_count(letter_details['page_count'])
|
||||
is_test_key = notification.key_type == KEY_TYPE_TEST
|
||||
|
||||
# Updating the notification needs to happen before the file is moved. This is so that if updating the
|
||||
# notification fails, the task can retry because the file is in the same place.
|
||||
update_letter_pdf_status(
|
||||
reference=notification.reference,
|
||||
status=NOTIFICATION_DELIVERED if is_test_key else NOTIFICATION_CREATED,
|
||||
billable_units=billable_units,
|
||||
recipient_address=letter_details['address']
|
||||
)
|
||||
|
||||
# The original filename could be wrong because we didn't know the postage.
|
||||
# Now we know if the letter is international, we can check what the filename should be.
|
||||
upload_file_name = generate_letter_pdf_filename(
|
||||
reference=notification.reference,
|
||||
created_at=notification.created_at,
|
||||
ignore_folder=True,
|
||||
postage=notification.postage
|
||||
)
|
||||
|
||||
move_sanitised_letter_to_test_or_live_pdf_bucket(
|
||||
filename,
|
||||
is_test_key,
|
||||
notification.created_at,
|
||||
upload_file_name,
|
||||
)
|
||||
# We've moved the sanitised PDF from the sanitise bucket, but still need to delete the original file:
|
||||
original_pdf_object.delete()
|
||||
|
||||
except BotoClientError:
|
||||
# Boto exceptions are likely to be caused by the file(s) being in the wrong place, so retrying won't help -
|
||||
# we'll need to manually investigate
|
||||
current_app.logger.exception(
|
||||
f"Boto error when processing sanitised letter for notification {notification.id} (file {filename})"
|
||||
)
|
||||
update_notification_status_by_id(notification.id, NOTIFICATION_TECHNICAL_FAILURE)
|
||||
raise NotificationTechnicalFailureException
|
||||
except Exception:
|
||||
try:
|
||||
current_app.logger.exception(
|
||||
"RETRY: calling process_sanitised_letter task for notification {} failed".format(notification.id)
|
||||
)
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
message = "RETRY FAILED: Max retries reached. " \
|
||||
"The task process_sanitised_letter 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)
|
||||
|
||||
|
||||
def _move_invalid_letter_and_update_status(
|
||||
*, notification, filename, scan_pdf_object, message=None, invalid_pages=None, page_count=None
|
||||
):
|
||||
try:
|
||||
move_scan_to_invalid_pdf_bucket(
|
||||
source_filename=filename,
|
||||
message=message,
|
||||
invalid_pages=invalid_pages,
|
||||
page_count=page_count
|
||||
)
|
||||
scan_pdf_object.delete()
|
||||
|
||||
update_letter_pdf_status(
|
||||
reference=notification.reference,
|
||||
status=NOTIFICATION_VALIDATION_FAILED,
|
||||
billable_units=0)
|
||||
except BotoClientError:
|
||||
current_app.logger.exception(
|
||||
"Error when moving letter with id {} to invalid PDF bucket".format(notification.id)
|
||||
)
|
||||
update_notification_status_by_id(notification.id, NOTIFICATION_TECHNICAL_FAILURE)
|
||||
raise NotificationTechnicalFailureException
|
||||
|
||||
|
||||
@notify_celery.task(name='process-virus-scan-failed')
|
||||
def process_virus_scan_failed(filename):
|
||||
move_failed_pdf(filename, ScanErrorType.FAILURE)
|
||||
reference = get_reference_from_filename(filename)
|
||||
notification = dao_get_notification_by_reference(reference)
|
||||
updated_count = update_letter_pdf_status(reference, NOTIFICATION_VIRUS_SCAN_FAILED, billable_units=0)
|
||||
|
||||
if updated_count != 1:
|
||||
raise Exception(
|
||||
"There should only be one letter notification for each reference. Found {} notifications".format(
|
||||
updated_count
|
||||
)
|
||||
)
|
||||
|
||||
error = VirusScanError('notification id {} Virus scan failed: {}'.format(notification.id, filename))
|
||||
current_app.logger.exception(error)
|
||||
raise error
|
||||
|
||||
|
||||
@notify_celery.task(name='process-virus-scan-error')
|
||||
def process_virus_scan_error(filename):
|
||||
move_failed_pdf(filename, ScanErrorType.ERROR)
|
||||
reference = get_reference_from_filename(filename)
|
||||
notification = dao_get_notification_by_reference(reference)
|
||||
updated_count = update_letter_pdf_status(reference, NOTIFICATION_TECHNICAL_FAILURE, billable_units=0)
|
||||
|
||||
if updated_count != 1:
|
||||
raise Exception(
|
||||
"There should only be one letter notification for each reference. Found {} notifications".format(
|
||||
updated_count
|
||||
)
|
||||
)
|
||||
error = VirusScanError('notification id {} Virus scan error: {}'.format(notification.id, filename))
|
||||
current_app.logger.exception(error)
|
||||
raise error
|
||||
|
||||
|
||||
def update_letter_pdf_status(reference, status, billable_units, recipient_address=None):
|
||||
postage = None
|
||||
if recipient_address:
|
||||
# fix allow_international_letters
|
||||
postage = PostalAddress(raw_address=recipient_address.replace(',', '\n'),
|
||||
allow_international_letters=True
|
||||
).postage
|
||||
postage = postage if postage in INTERNATIONAL_POSTAGE_TYPES else None
|
||||
update_dict = {'status': status, 'billable_units': billable_units, 'updated_at': datetime.utcnow()}
|
||||
if postage:
|
||||
update_dict.update({'postage': postage, 'international': True})
|
||||
if recipient_address:
|
||||
update_dict['to'] = recipient_address
|
||||
update_dict['normalised_to'] = ''.join(recipient_address.split()).lower()
|
||||
return dao_update_notifications_by_reference(
|
||||
references=[reference],
|
||||
update_dict=update_dict)[0]
|
||||
|
||||
|
||||
def replay_letters_in_error(filename=None):
|
||||
# This method can be used to replay letters that end up in the ERROR directory.
|
||||
# We had an incident where clamAV was not processing the virus scan.
|
||||
if filename:
|
||||
move_error_pdf_to_scan_bucket(filename)
|
||||
# call task to add the filename to anti virus queue
|
||||
current_app.logger.info("Calling scan_file for: {}".format(filename))
|
||||
|
||||
if current_app.config['ANTIVIRUS_ENABLED']:
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.SCAN_FILE,
|
||||
kwargs={'filename': filename},
|
||||
queue=QueueNames.ANTIVIRUS,
|
||||
)
|
||||
else:
|
||||
# stub out antivirus in dev
|
||||
sanitise_letter.apply_async(
|
||||
[filename],
|
||||
queue=QueueNames.LETTERS
|
||||
)
|
||||
else:
|
||||
error_files = get_file_names_from_error_bucket()
|
||||
for item in error_files:
|
||||
moved_file_name = item.key.split('/')[1]
|
||||
current_app.logger.info("Calling scan_file for: {}".format(moved_file_name))
|
||||
move_error_pdf_to_scan_bucket(moved_file_name)
|
||||
# call task to add the filename to anti virus queue
|
||||
if current_app.config['ANTIVIRUS_ENABLED']:
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.SCAN_FILE,
|
||||
kwargs={'filename': moved_file_name},
|
||||
queue=QueueNames.ANTIVIRUS,
|
||||
)
|
||||
else:
|
||||
# stub out antivirus in dev
|
||||
sanitise_letter.apply_async(
|
||||
[filename],
|
||||
queue=QueueNames.LETTERS
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name='resanitise-pdf')
|
||||
def resanitise_pdf(notification_id):
|
||||
"""
|
||||
`notification_id` is the notification id for a PDF letter which was either uploaded or sent using the API.
|
||||
|
||||
This task calls the `recreate_pdf_for_precompiled_letter` template preview task which recreates the
|
||||
PDF for a letter which is already sanitised and in the letters-pdf bucket. The new file that is generated
|
||||
will then overwrite the existing letter in the letters-pdf bucket.
|
||||
"""
|
||||
notification = get_notification_by_id(notification_id)
|
||||
|
||||
# folder_name is the folder that the letter is in the letters-pdf bucket e.g. '2021-10-10/'
|
||||
folder_name = get_folder_name(notification.created_at)
|
||||
|
||||
filename = generate_letter_pdf_filename(
|
||||
reference=notification.reference,
|
||||
created_at=notification.created_at,
|
||||
ignore_folder=True,
|
||||
postage=notification.postage
|
||||
)
|
||||
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.RECREATE_PDF_FOR_PRECOMPILED_LETTER,
|
||||
kwargs={
|
||||
'notification_id': str(notification.id),
|
||||
'file_location': f'{folder_name}{filename}',
|
||||
'allow_international_letters': notification.service.has_permission(
|
||||
INTERNATIONAL_LETTERS
|
||||
),
|
||||
},
|
||||
queue=QueueNames.SANITISE_LETTERS,
|
||||
)
|
||||
@@ -1,12 +1,16 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from flask import current_app
|
||||
from notifications_utils.clients.zendesk.zendesk_client import (
|
||||
NotifySupportTicket,
|
||||
)
|
||||
from notifications_utils.timezones import convert_utc_to_bst
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app import notify_celery
|
||||
from app import notify_celery, statsd_client, zendesk_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
|
||||
from app.dao.fact_processing_time_dao import insert_update_processing_time
|
||||
@@ -14,7 +18,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 +28,31 @@ 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, utc_now
|
||||
from app.models import (
|
||||
EMAIL_TYPE,
|
||||
KEY_TYPE_NORMAL,
|
||||
LETTER_TYPE,
|
||||
NOTIFICATION_SENDING,
|
||||
SMS_TYPE,
|
||||
FactProcessingTime,
|
||||
Notification,
|
||||
)
|
||||
from app.notifications.notifications_ses_callback import (
|
||||
check_and_queue_callback_task,
|
||||
)
|
||||
from app.utils import get_london_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])
|
||||
|
||||
|
||||
@notify_celery.task(name="remove_letter_jobs")
|
||||
@cronitor("remove_letter_jobs")
|
||||
def remove_letter_csv_files():
|
||||
_remove_csv_files([LETTER_TYPE])
|
||||
|
||||
|
||||
def _remove_csv_files(job_types):
|
||||
@@ -44,153 +63,121 @@ 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 = utc_now()
|
||||
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
|
||||
try:
|
||||
if job.processing_started is not None:
|
||||
acceptable_finish_time = job.processing_started + timedelta(minutes=5)
|
||||
except TypeError:
|
||||
current_app.logger.exception(
|
||||
f"Job ID {job.id} processing_started is {job.processing_started}.",
|
||||
)
|
||||
raise
|
||||
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)
|
||||
delete_letter_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')
|
||||
|
||||
|
||||
@notify_celery.task(name="delete-letter-notifications")
|
||||
@cronitor("delete-letter-notifications")
|
||||
def delete_letter_notifications_older_than_retention():
|
||||
_delete_notifications_older_than_retention_by_type('letter')
|
||||
|
||||
|
||||
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(
|
||||
utc_now()
|
||||
).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_london_midnight_in_utc(
|
||||
convert_utc_to_bst(datetime.utcnow()).date() - timedelta(days=f.days_of_retention)
|
||||
)
|
||||
|
||||
seven_days_ago = get_midnight_in_utc(utc_now()).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_london_midnight_in_utc(convert_utc_to_bst(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
|
||||
):
|
||||
start = utc_now()
|
||||
@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,
|
||||
service_id,
|
||||
datetime_to_delete_before,
|
||||
)
|
||||
if num_deleted:
|
||||
end = utc_now()
|
||||
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 = utc_now() - timedelta(
|
||||
seconds=current_app.config.get("SENDING_NOTIFICATIONS_TIMEOUT_PERIOD")
|
||||
cutoff_time = datetime.utcnow() - timedelta(
|
||||
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")
|
||||
@cronitor("delete-inbound-sms")
|
||||
def delete_inbound_sms():
|
||||
try:
|
||||
start = utc_now()
|
||||
start = datetime.utcnow()
|
||||
deleted = delete_inbound_sms_older_than_retention()
|
||||
current_app.logger.info(
|
||||
"Delete inbound sms job started {} finished {} deleted {} inbound sms notifications".format(
|
||||
start, utc_now(), deleted
|
||||
start,
|
||||
datetime.utcnow(),
|
||||
deleted
|
||||
)
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
@@ -198,23 +185,127 @@ def delete_inbound_sms():
|
||||
raise
|
||||
|
||||
|
||||
@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"
|
||||
if local_date is None:
|
||||
# if a date is not provided, we run against yesterdays data
|
||||
local_date = (utc_now() - timedelta(days=1)).date()
|
||||
else:
|
||||
local_date = datetime.strptime(local_date, "%Y-%m-%d").date()
|
||||
@notify_celery.task(name="raise-alert-if-letter-notifications-still-sending")
|
||||
@cronitor("raise-alert-if-letter-notifications-still-sending")
|
||||
def raise_alert_if_letter_notifications_still_sending():
|
||||
still_sending_count, sent_date = get_letter_notifications_still_sending_when_they_shouldnt_be()
|
||||
|
||||
start_time = get_midnight_in_utc(local_date)
|
||||
end_time = get_midnight_in_utc(local_date + timedelta(days=1))
|
||||
if still_sending_count:
|
||||
message = "There are {} letters in the 'sending' state from {}".format(
|
||||
still_sending_count,
|
||||
sent_date.strftime('%A %d %B')
|
||||
)
|
||||
# Only send alerts in production
|
||||
if current_app.config['NOTIFY_ENVIRONMENT'] in ['live', 'production', 'test']:
|
||||
message += ". Resolve using https://github.com/alphagov/notifications-manuals/wiki/Support-Runbook#deal-with-letters-still-in-sending" # noqa
|
||||
|
||||
ticket = NotifySupportTicket(
|
||||
subject=f"[{current_app.config['NOTIFY_ENVIRONMENT']}] Letters still sending",
|
||||
email_ccs=current_app.config['DVLA_EMAIL_ADDRESSES'],
|
||||
message=message,
|
||||
ticket_type=NotifySupportTicket.TYPE_INCIDENT,
|
||||
technical_ticket=True,
|
||||
ticket_categories=['notify_letters']
|
||||
)
|
||||
zendesk_client.send_ticket_to_zendesk(ticket)
|
||||
else:
|
||||
current_app.logger.info(message)
|
||||
|
||||
|
||||
def get_letter_notifications_still_sending_when_they_shouldnt_be():
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
# Do nothing on the weekend
|
||||
if today.isoweekday() in {6, 7}: # sat, sun
|
||||
return 0, None
|
||||
|
||||
if today.isoweekday() in {1, 2}: # mon, tues. look for files from before the weekend
|
||||
offset_days = 4
|
||||
else:
|
||||
offset_days = 2
|
||||
|
||||
expected_sent_date = today - timedelta(days=offset_days)
|
||||
|
||||
q = Notification.query.filter(
|
||||
Notification.notification_type == LETTER_TYPE,
|
||||
Notification.status == NOTIFICATION_SENDING,
|
||||
Notification.key_type == KEY_TYPE_NORMAL,
|
||||
func.date(Notification.sent_at) <= expected_sent_date
|
||||
)
|
||||
|
||||
return q.count(), expected_sent_date
|
||||
|
||||
|
||||
@notify_celery.task(name='raise-alert-if-no-letter-ack-file')
|
||||
@cronitor('raise-alert-if-no-letter-ack-file')
|
||||
def letter_raise_alert_if_no_ack_file_for_zip():
|
||||
# get a list of zip files since yesterday
|
||||
zip_file_set = set()
|
||||
today_str = datetime.utcnow().strftime('%Y-%m-%d')
|
||||
yesterday = datetime.now(tz=pytz.utc) - timedelta(days=1) # AWS datetime format
|
||||
|
||||
for key in s3.get_list_of_files_by_suffix(bucket_name=current_app.config['LETTERS_PDF_BUCKET_NAME'],
|
||||
subfolder=today_str + '/zips_sent',
|
||||
suffix='.TXT'):
|
||||
subname = key.split('/')[-1] # strip subfolder in name
|
||||
zip_file_set.add(subname.upper().replace('.ZIP.TXT', ''))
|
||||
|
||||
# get acknowledgement file
|
||||
ack_file_set = set()
|
||||
|
||||
for key in s3.get_list_of_files_by_suffix(bucket_name=current_app.config['DVLA_RESPONSE_BUCKET_NAME'],
|
||||
subfolder='root/dispatch', suffix='.ACK.txt', last_modified=yesterday):
|
||||
ack_file_set.add(key.lstrip('root/dispatch').upper().replace('.ACK.TXT', '')) # noqa
|
||||
|
||||
message = '\n'.join([
|
||||
"Letter ack file does not contain all zip files sent."
|
||||
"",
|
||||
f"See runbook at https://github.com/alphagov/notifications-manuals/wiki/Support-Runbook#letter-ack-file-does-not-contain-all-zip-files-sent\n", # noqa
|
||||
f"pdf bucket: {current_app.config['LETTERS_PDF_BUCKET_NAME']}, subfolder: {datetime.utcnow().strftime('%Y-%m-%d')}/zips_sent", # noqa
|
||||
f"ack bucket: {current_app.config['DVLA_RESPONSE_BUCKET_NAME']}",
|
||||
"",
|
||||
f"Missing ack for zip files: {str(sorted(zip_file_set - ack_file_set))}",
|
||||
])
|
||||
|
||||
# strip empty element before comparison
|
||||
ack_file_set.discard('')
|
||||
zip_file_set.discard('')
|
||||
|
||||
if len(zip_file_set - ack_file_set) > 0:
|
||||
if current_app.config['NOTIFY_ENVIRONMENT'] in ['live', 'production', 'test']:
|
||||
ticket = NotifySupportTicket(
|
||||
subject="Letter acknowledge error",
|
||||
message=message,
|
||||
ticket_type=NotifySupportTicket.TYPE_INCIDENT,
|
||||
technical_ticket=True,
|
||||
ticket_categories=['notify_letters']
|
||||
)
|
||||
zendesk_client.send_ticket_to_zendesk(ticket)
|
||||
current_app.logger.error(message)
|
||||
|
||||
if len(ack_file_set - zip_file_set) > 0:
|
||||
current_app.logger.info(
|
||||
"letter ack contains zip that is not for today: {}".format(ack_file_set - zip_file_set)
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(name='save-daily-notification-processing-time')
|
||||
@cronitor("save-daily-notification-processing-time")
|
||||
def save_daily_notification_processing_time(bst_date=None):
|
||||
# bst_date is a string in the format of "YYYY-MM-DD"
|
||||
if bst_date is None:
|
||||
# if a date is not provided, we run against yesterdays data
|
||||
bst_date = (datetime.utcnow() - timedelta(days=1)).date()
|
||||
else:
|
||||
bst_date = datetime.strptime(bst_date, "%Y-%m-%d").date()
|
||||
|
||||
start_time = get_london_midnight_in_utc(bst_date)
|
||||
end_time = get_london_midnight_in_utc(bst_date + timedelta(days=1))
|
||||
result = dao_get_notifications_processing_time_stats(start_time, end_time)
|
||||
insert_update_processing_time(
|
||||
FactProcessingTime(
|
||||
local_date=local_date,
|
||||
bst_date=bst_date,
|
||||
messages_total=result.messages_total,
|
||||
messages_within_10_secs=result.messages_within_10_secs,
|
||||
messages_within_10_secs=result.messages_within_10_secs
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,111 +1,79 @@
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import iso8601
|
||||
from celery.exceptions import Retry
|
||||
from flask import current_app, json
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from app import notify_celery
|
||||
from app.celery.service_callback_tasks import (
|
||||
create_complaint_callback_data,
|
||||
create_delivery_status_callback_data,
|
||||
send_complaint_to_service,
|
||||
send_delivery_status_to_service,
|
||||
)
|
||||
from app.config import Config, QueueNames
|
||||
from app import notify_celery, statsd_client
|
||||
from app.clients.email.aws_ses import get_aws_responses
|
||||
from app.config import QueueNames
|
||||
from app.dao import notifications_dao
|
||||
from app.dao.complaint_dao import save_complaint
|
||||
from app.dao.notifications_dao import dao_get_notification_history_by_reference
|
||||
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.models import NOTIFICATION_PENDING, NOTIFICATION_SENDING
|
||||
from app.notifications.notifications_ses_callback import (
|
||||
_check_and_queue_complaint_callback_task,
|
||||
check_and_queue_callback_task,
|
||||
determine_notification_bounce_type,
|
||||
handle_complaint,
|
||||
)
|
||||
from app.enums import CallbackType, NotificationStatus
|
||||
from app.models import Complaint
|
||||
from app.utils import utc_now
|
||||
|
||||
|
||||
@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"])
|
||||
notification_type = ses_message["notificationType"]
|
||||
# TODO remove after smoke testing on prod is implemented
|
||||
current_app.logger.info(
|
||||
f"Attempting to process SES delivery status message "
|
||||
f"from SNS with type: {notification_type} and body: {ses_message}"
|
||||
)
|
||||
ses_message = json.loads(response['Message'])
|
||||
notification_type = ses_message['notificationType']
|
||||
bounce_message = None
|
||||
|
||||
if notification_type == "Bounce":
|
||||
bounce_message = determine_notification_bounce_type(ses_message)
|
||||
elif notification_type == "Complaint":
|
||||
if notification_type == 'Bounce':
|
||||
notification_type, bounce_message = determine_notification_bounce_type(notification_type, ses_message)
|
||||
elif notification_type == 'Complaint':
|
||||
_check_and_queue_complaint_callback_task(*handle_complaint(ses_message))
|
||||
return True
|
||||
|
||||
aws_response_dict = get_aws_responses(ses_message)
|
||||
aws_response_dict = get_aws_responses(notification_type)
|
||||
|
||||
notification_status = aws_response_dict["notification_status"]
|
||||
reference = ses_message["mail"]["messageId"]
|
||||
notification_status = aws_response_dict['notification_status']
|
||||
reference = ses_message['mail']['messageId']
|
||||
|
||||
try:
|
||||
notification = notifications_dao.dao_get_notification_by_reference(
|
||||
reference
|
||||
)
|
||||
notification = notifications_dao.dao_get_notification_or_history_by_reference(reference=reference)
|
||||
except NoResultFound:
|
||||
message_time = iso8601.parse_date(ses_message["mail"]["timestamp"]).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
if utc_now() - message_time < timedelta(minutes=5):
|
||||
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}"
|
||||
f"(while attempting update to {notification_status}). "
|
||||
f"Callback may have arrived before notification was"
|
||||
f"persisted to the DB. Adding task to retry queue"
|
||||
)
|
||||
self.retry(
|
||||
queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME
|
||||
f"notification not found for reference: {reference} (update to {notification_status}). "
|
||||
f"Callback may have arrived before notification was persisted to the DB. Adding task to retry queue"
|
||||
)
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
f"Notification not found for reference: {reference} "
|
||||
f"(while attempting update to {notification_status})"
|
||||
f"notification not found for reference: {reference} (update to {notification_status})"
|
||||
)
|
||||
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=notification_status
|
||||
)
|
||||
return
|
||||
|
||||
notifications_dao._update_notification_status(
|
||||
notification=notification,
|
||||
status=notification_status,
|
||||
provider_response=aws_response_dict["provider_response"],
|
||||
)
|
||||
|
||||
if not aws_response_dict["success"]:
|
||||
current_app.logger.info(
|
||||
"SES delivery failed: notification id {} and reference {} has error found. Status {}".format(
|
||||
notification.id, reference, aws_response_dict["message"]
|
||||
)
|
||||
)
|
||||
else:
|
||||
current_app.logger.info(
|
||||
"SES callback return status of {} for notification: {}".format(
|
||||
notification_status, notification.id
|
||||
)
|
||||
notifications_dao.dao_update_notifications_by_reference(
|
||||
references=[reference],
|
||||
update_dict={'status': notification_status}
|
||||
)
|
||||
|
||||
statsd_client.incr('callback.ses.{}'.format(notification_status))
|
||||
|
||||
if notification.sent_at:
|
||||
statsd_client.timing_with_dates(
|
||||
f'callback.ses.{notification_status}.elapsed-time',
|
||||
datetime.utcnow(),
|
||||
notification.sent_at
|
||||
)
|
||||
|
||||
check_and_queue_callback_task(notification)
|
||||
@@ -115,162 +83,6 @@ def process_ses_results(self, response):
|
||||
except Retry:
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
current_app.logger.exception("Error processing SES results")
|
||||
self.retry(queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME)
|
||||
|
||||
|
||||
def determine_notification_bounce_type(ses_message):
|
||||
notification_type = ses_message["notificationType"]
|
||||
if notification_type in ["Delivery", "Complaint"]:
|
||||
return notification_type
|
||||
|
||||
if notification_type != "Bounce":
|
||||
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("}", ")")
|
||||
)
|
||||
)
|
||||
if ses_message["bounce"]["bounceType"] == "Permanent":
|
||||
return "Permanent"
|
||||
return "Temporary"
|
||||
|
||||
|
||||
def determine_notification_type(ses_message):
|
||||
notification_type = ses_message["notificationType"]
|
||||
if notification_type not in ["Bounce", "Complaint", "Delivery"]:
|
||||
raise KeyError(f"Unhandled sns notification type {notification_type}")
|
||||
if notification_type == "Bounce":
|
||||
return determine_notification_bounce_type(ses_message)
|
||||
return notification_type
|
||||
|
||||
|
||||
def _determine_provider_response(ses_message):
|
||||
if ses_message["notificationType"] != "Bounce":
|
||||
return None
|
||||
|
||||
bounce_type = ses_message["bounce"]["bounceType"]
|
||||
bounce_subtype = ses_message["bounce"]["bounceSubType"]
|
||||
|
||||
# See https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html
|
||||
if bounce_type == "Permanent" and bounce_subtype == "Suppressed":
|
||||
return "The email address is on our email provider suppression list"
|
||||
elif bounce_type == "Permanent" and bounce_subtype == "OnAccountSuppressionList":
|
||||
return "The email address is on the GC Notify suppression list"
|
||||
elif bounce_type == "Transient" and bounce_subtype == "AttachmentRejected":
|
||||
return "The email was rejected because of its attachments"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_aws_responses(ses_message):
|
||||
status = determine_notification_type(ses_message)
|
||||
|
||||
base = {
|
||||
"Permanent": {
|
||||
"message": "Hard bounced",
|
||||
"success": False,
|
||||
"notification_status": NotificationStatus.PERMANENT_FAILURE,
|
||||
},
|
||||
"Temporary": {
|
||||
"message": "Soft bounced",
|
||||
"success": False,
|
||||
"notification_status": NotificationStatus.TEMPORARY_FAILURE,
|
||||
},
|
||||
"Delivery": {
|
||||
"message": "Delivered",
|
||||
"success": True,
|
||||
"notification_status": NotificationStatus.DELIVERED,
|
||||
},
|
||||
"Complaint": {
|
||||
"message": "Complaint",
|
||||
"success": True,
|
||||
"notification_status": NotificationStatus.DELIVERED,
|
||||
},
|
||||
}[status]
|
||||
|
||||
base["provider_response"] = _determine_provider_response(ses_message)
|
||||
|
||||
return base
|
||||
|
||||
|
||||
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("}", ")")
|
||||
)
|
||||
)
|
||||
try:
|
||||
reference = ses_message["mail"]["messageId"]
|
||||
except KeyError:
|
||||
current_app.logger.exception(
|
||||
"Complaint from SES failed to get reference from message"
|
||||
)
|
||||
return
|
||||
notification = dao_get_notification_history_by_reference(reference)
|
||||
ses_complaint = ses_message.get(CallbackType.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
|
||||
),
|
||||
complaint_date=ses_complaint.get("timestamp", None) if ses_complaint else None,
|
||||
)
|
||||
save_complaint(complaint)
|
||||
return complaint, notification, recipient_email
|
||||
|
||||
|
||||
def remove_mail_headers(dict_to_edit):
|
||||
if dict_to_edit["mail"].get("headers"):
|
||||
dict_to_edit["mail"].pop("headers")
|
||||
if dict_to_edit["mail"].get("commonHeaders"):
|
||||
dict_to_edit["mail"].pop("commonHeaders")
|
||||
|
||||
|
||||
def remove_emails_from_bounce(bounce_dict):
|
||||
remove_mail_headers(bounce_dict)
|
||||
bounce_dict["mail"].pop("destination", None)
|
||||
bounce_dict["bounce"].pop("bouncedRecipients", None)
|
||||
|
||||
|
||||
def remove_emails_from_complaint(complaint_dict):
|
||||
remove_mail_headers(complaint_dict)
|
||||
complaint_dict[CallbackType.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
|
||||
)
|
||||
if 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
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.exception('Error processing SES results: {}'.format(type(e)))
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
|
||||
94
app/celery/process_sms_client_response_tasks.py
Normal file
94
app/celery/process_sms_client_response_tasks.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.template import SMSMessageTemplate
|
||||
|
||||
from app import notify_celery, statsd_client
|
||||
from app.clients import ClientException
|
||||
from app.clients.sms.firetext import get_firetext_responses
|
||||
from app.clients.sms.mmg import get_mmg_responses
|
||||
from app.clients.sms.reach import get_reach_responses
|
||||
from app.dao import notifications_dao
|
||||
from app.dao.templates_dao import dao_get_template_by_id
|
||||
from app.models import NOTIFICATION_PENDING
|
||||
from app.notifications.notifications_ses_callback import (
|
||||
check_and_queue_callback_task,
|
||||
)
|
||||
|
||||
sms_response_mapper = {
|
||||
'MMG': get_mmg_responses,
|
||||
'Firetext': get_firetext_responses,
|
||||
'Reach': get_reach_responses
|
||||
}
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="process-sms-client-response", max_retries=5, default_retry_delay=300)
|
||||
def process_sms_client_response(self, status, provider_reference, client_name, detailed_status_code=None):
|
||||
# validate reference
|
||||
try:
|
||||
uuid.UUID(provider_reference, version=4)
|
||||
except ValueError as e:
|
||||
current_app.logger.exception(f'{client_name} callback with invalid reference {provider_reference}')
|
||||
raise e
|
||||
|
||||
response_parser = sms_response_mapper[client_name]
|
||||
|
||||
# validate status
|
||||
try:
|
||||
notification_status, detailed_status = response_parser(status, detailed_status_code)
|
||||
current_app.logger.info(
|
||||
f'{client_name} callback returned status of {notification_status}'
|
||||
f'({status}): {detailed_status}({detailed_status_code}) for reference: {provider_reference}'
|
||||
)
|
||||
except KeyError:
|
||||
_process_for_status(
|
||||
notification_status='technical-failure',
|
||||
client_name=client_name,
|
||||
provider_reference=provider_reference
|
||||
)
|
||||
raise ClientException(f'{client_name} callback failed: status {status} not found.')
|
||||
|
||||
_process_for_status(
|
||||
notification_status=notification_status,
|
||||
client_name=client_name,
|
||||
provider_reference=provider_reference,
|
||||
detailed_status_code=detailed_status_code
|
||||
)
|
||||
|
||||
|
||||
def _process_for_status(notification_status, client_name, provider_reference, detailed_status_code=None):
|
||||
# record stats
|
||||
notification = notifications_dao.update_notification_status_by_id(
|
||||
notification_id=provider_reference,
|
||||
status=notification_status,
|
||||
sent_by=client_name.lower(),
|
||||
detailed_status_code=detailed_status_code
|
||||
)
|
||||
if not notification:
|
||||
return
|
||||
|
||||
statsd_client.incr('callback.{}.{}'.format(client_name.lower(), notification_status))
|
||||
|
||||
if notification.sent_at:
|
||||
statsd_client.timing_with_dates(
|
||||
f'callback.{client_name.lower()}.{notification_status}.elapsed-time',
|
||||
datetime.utcnow(),
|
||||
notification.sent_at
|
||||
)
|
||||
|
||||
if notification.billable_units == 0:
|
||||
service = notification.service
|
||||
template_model = dao_get_template_by_id(notification.template_id, notification.template_version)
|
||||
|
||||
template = SMSMessageTemplate(
|
||||
template_model.__dict__,
|
||||
values=notification.personalisation,
|
||||
prefix=service.name,
|
||||
show_prefix=service.prefix_sms,
|
||||
)
|
||||
notification.billable_units = template.fragment_count
|
||||
notifications_dao.dao_update_notification(notification)
|
||||
|
||||
if notification_status != NOTIFICATION_PENDING:
|
||||
check_and_queue_callback_task(notification)
|
||||
@@ -1,112 +1,62 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from app import 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 Config, QueueNames
|
||||
from app.config import QueueNames
|
||||
from app.dao import notifications_dao
|
||||
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
|
||||
from notifications_utils.clients.redis import total_limit_cache_key
|
||||
from app.models import NOTIFICATION_TECHNICAL_FAILURE
|
||||
|
||||
|
||||
@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
|
||||
send_to_providers.send_sms_to_provider(notification)
|
||||
|
||||
cache_key = total_limit_cache_key(notification.service_id)
|
||||
redis_store.incr(cache_key)
|
||||
|
||||
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
|
||||
)
|
||||
else:
|
||||
current_app.logger.exception(
|
||||
"SMS notification delivery for id: {} failed".format(notification_id),
|
||||
"SMS notification delivery for id: {} failed".format(notification_id)
|
||||
)
|
||||
|
||||
try:
|
||||
if self.request.retries == 0:
|
||||
self.retry(
|
||||
queue=QueueNames.RETRY,
|
||||
countdown=0,
|
||||
expires=Config.DEFAULT_REDIS_EXPIRE_TIME,
|
||||
)
|
||||
self.retry(queue=QueueNames.RETRY, countdown=0)
|
||||
else:
|
||||
self.retry(
|
||||
queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME
|
||||
)
|
||||
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=30
|
||||
)
|
||||
@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}")
|
||||
recipient = redis_store.get(f"email-recipient-{notification_id}")
|
||||
if personalisation:
|
||||
notification.personalisation = json.loads(personalisation)
|
||||
if recipient:
|
||||
notification.recipient = json.loads(recipient)
|
||||
|
||||
send_to_providers.send_email_to_provider(notification)
|
||||
except EmailClientNonRetryableException:
|
||||
current_app.logger.exception(f"Email notification {notification_id} failed")
|
||||
update_notification_status_by_id(notification_id, "technical-failure")
|
||||
except EmailClientNonRetryableException as e:
|
||||
current_app.logger.exception(
|
||||
f"Email notification {notification_id} failed: {e}"
|
||||
)
|
||||
update_notification_status_by_id(notification_id, 'technical-failure')
|
||||
except Exception as e:
|
||||
try:
|
||||
if isinstance(e, AwsSesClientThrottlingSendRateException):
|
||||
@@ -118,17 +68,10 @@ def deliver_email(self, notification_id):
|
||||
f"RETRY: Email notification {notification_id} failed"
|
||||
)
|
||||
|
||||
self.retry(queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME)
|
||||
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,15 +1,18 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.timezones import convert_utc_to_bst
|
||||
|
||||
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.utils import utc_now
|
||||
from app.models import EMAIL_TYPE, LETTER_TYPE, SMS_TYPE
|
||||
|
||||
|
||||
@notify_celery.task(name="create-nightly-billing")
|
||||
@@ -18,15 +21,16 @@ 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 = utc_now().date() - timedelta(days=1)
|
||||
day_start = convert_utc_to_bst(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()
|
||||
for i in range(0, 10):
|
||||
for i in range(0, 4):
|
||||
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}"
|
||||
@@ -37,15 +41,15 @@ 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 = utc_now()
|
||||
start = datetime.utcnow()
|
||||
transit_data = fetch_billing_data_for_day(process_day=process_day)
|
||||
end = utc_now()
|
||||
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:
|
||||
@@ -68,6 +72,10 @@ def create_nightly_notification_status():
|
||||
because all outstanding email / SMS are "timed out" after 3 days, and
|
||||
we reject delivery receipts after this point.
|
||||
|
||||
- Letter statuses don't change after 9 days. There's no "timeout" for
|
||||
letters but this is the longest we've had to cope with in the past - due
|
||||
to major issues with our print provider.
|
||||
|
||||
Because the time range of the task exceeds the minimum possible retention
|
||||
period (3 days), we need to choose which table to query for each service.
|
||||
|
||||
@@ -79,10 +87,10 @@ def create_nightly_notification_status():
|
||||
mean the aggregated results are temporarily incorrect.
|
||||
"""
|
||||
|
||||
yesterday = utc_now().date() - timedelta(days=1)
|
||||
yesterday = convert_utc_to_bst(datetime.utcnow()).date() - timedelta(days=1)
|
||||
|
||||
for notification_type in (NotificationType.SMS, NotificationType.EMAIL):
|
||||
days = 4
|
||||
for notification_type in [SMS_TYPE, EMAIL_TYPE, LETTER_TYPE]:
|
||||
days = 10 if notification_type == LETTER_TYPE else 4
|
||||
|
||||
for i in range(days):
|
||||
process_day = yesterday - timedelta(days=i)
|
||||
@@ -94,30 +102,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 = utc_now()
|
||||
start = datetime.utcnow()
|
||||
update_fact_notification_status(
|
||||
process_day=process_day,
|
||||
notification_type=notification_type,
|
||||
service_id=service_id,
|
||||
service_id=service_id
|
||||
)
|
||||
|
||||
end = utc_now()
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.s3 import s3upload
|
||||
from requests import HTTPError, request
|
||||
|
||||
from app import notify_celery
|
||||
from app.aws.s3 import file_exists
|
||||
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"
|
||||
delivered = "2028675309"
|
||||
temp_fail = "7700900003"
|
||||
perm_fail = "7700900002"
|
||||
delivered = "7700900001"
|
||||
|
||||
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"}
|
||||
def send_sms_response(provider, reference, to):
|
||||
if provider == "mmg":
|
||||
body = mmg_callback(reference, to)
|
||||
headers = {"Content-type": "application/json"}
|
||||
else:
|
||||
headers = {"Content-type": "application/x-www-form-urlencoded"}
|
||||
body = firetext_callback(reference, to)
|
||||
# to simulate getting a temporary_failure from firetext
|
||||
# we need to send a pending status updated then a permanent-failure
|
||||
if body['status'] == '2': # pending status
|
||||
make_request(SMS_TYPE, provider, body, headers)
|
||||
# 1 is a declined status for firetext, will result in a temp-failure
|
||||
body = {'mobile': to,
|
||||
'status': "1",
|
||||
'time': '2016-03-10 14:17:00',
|
||||
'reference': reference
|
||||
}
|
||||
|
||||
make_request(NotificationType.SMS, provider, body, headers)
|
||||
make_request(SMS_TYPE, provider, body, headers)
|
||||
|
||||
|
||||
def send_email_response(reference, to):
|
||||
@@ -36,17 +54,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 +78,224 @@ def make_request(notification_type, provider, data, headers):
|
||||
return response.json()
|
||||
|
||||
|
||||
def sns_callback(notification_id):
|
||||
notification = get_notification_by_id(notification_id)
|
||||
def mmg_callback(notification_id, to):
|
||||
"""
|
||||
status: 3 - delivered
|
||||
status: 4 - expired (temp failure)
|
||||
status: 5 - rejected (perm failure)
|
||||
"""
|
||||
|
||||
# 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
|
||||
}
|
||||
if to.strip().endswith(temp_fail):
|
||||
status = "4"
|
||||
elif to.strip().endswith(perm_fail):
|
||||
status = "5"
|
||||
else:
|
||||
status = "3"
|
||||
|
||||
return json.dumps({"reference": "mmg_reference",
|
||||
"CID": str(notification_id),
|
||||
"MSISDN": to,
|
||||
"status": status,
|
||||
"deliverytime": "2016-04-05 16:01:07"})
|
||||
|
||||
|
||||
def firetext_callback(notification_id, to):
|
||||
"""
|
||||
status: 0 - delivered
|
||||
status: 1 - perm failure
|
||||
"""
|
||||
if to.strip().endswith(perm_fail):
|
||||
status = "1"
|
||||
elif to.strip().endswith(temp_fail):
|
||||
status = "2"
|
||||
else:
|
||||
status = "0"
|
||||
return {
|
||||
'mobile': to,
|
||||
'status': status,
|
||||
'time': '2016-03-10 14:17:00',
|
||||
'reference': notification_id
|
||||
}
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="create-fake-letter-response-file", max_retries=5, default_retry_delay=300)
|
||||
def create_fake_letter_response_file(self, reference):
|
||||
now = datetime.utcnow()
|
||||
dvla_response_data = '{}|Sent|0|Sorted'.format(reference)
|
||||
|
||||
# try and find a filename that hasn't been taken yet - from a random time within the last 30 seconds
|
||||
for i in sorted(range(30), key=lambda _: random.random()):
|
||||
upload_file_name = 'NOTIFY-{}-RSP.TXT'.format((now - timedelta(seconds=i)).strftime('%Y%m%d%H%M%S'))
|
||||
if not file_exists(current_app.config['DVLA_RESPONSE_BUCKET_NAME'], upload_file_name):
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
'cant create fake letter response file for {} - too many files for that time already exist on s3'.format(
|
||||
reference
|
||||
)
|
||||
)
|
||||
|
||||
s3upload(
|
||||
filedata=dvla_response_data,
|
||||
region=current_app.config['AWS_REGION'],
|
||||
bucket_name=current_app.config['DVLA_RESPONSE_BUCKET_NAME'],
|
||||
file_location=upload_file_name
|
||||
)
|
||||
current_app.logger.info("Fake DVLA response file {}, content [{}], uploaded to {}, created at {}".format(
|
||||
upload_file_name, dvla_response_data, current_app.config['DVLA_RESPONSE_BUCKET_NAME'], now))
|
||||
|
||||
# on development we can't trigger SNS callbacks so we need to manually hit the DVLA callback endpoint
|
||||
if current_app.config['NOTIFY_ENVIRONMENT'] == 'development':
|
||||
make_request('letter', 'dvla', _fake_sns_s3_callback(upload_file_name), None)
|
||||
|
||||
|
||||
def _fake_sns_s3_callback(filename):
|
||||
message_contents = '{"Records":[{"s3":{"object":{"key":"%s"}}}]}' % (filename) # noqa
|
||||
return json.dumps({
|
||||
"Type": "Notification",
|
||||
"MessageId": "some-message-id",
|
||||
"Message": message_contents
|
||||
})
|
||||
|
||||
|
||||
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.eu-west-1.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:eu-west-1: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:eu-west-1: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.eu-west-1.amazonaws.com/SimpleNotificationService-[REDACTED].pem',
|
||||
'UnsubscribeUrl': 'https://sns.eu-west-1.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.eu-west-1.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:eu-west-1: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:eu-west-1: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.eu-west-1.amazonaws.com/SimpleNotificationService-[REDACTED]].pem',
|
||||
'UnsubscribeUrl': 'https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=[REDACTED]]',
|
||||
'MessageAttributes': {}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,63 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import between, select, union
|
||||
from notifications_utils.clients.zendesk.zendesk_client import (
|
||||
NotifySupportTicket,
|
||||
)
|
||||
from sqlalchemy import between
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app import db, notify_celery, redis_store, zendesk_client
|
||||
from app import db, notify_celery, zendesk_client
|
||||
from app.aws import s3
|
||||
from app.celery.broadcast_message_tasks import trigger_link_test
|
||||
from app.celery.letters_pdf_tasks import get_pdf_for_templated_letter
|
||||
from app.celery.tasks import (
|
||||
get_recipient_csv_and_template_and_sender_id,
|
||||
process_incomplete_jobs,
|
||||
process_job,
|
||||
process_row,
|
||||
)
|
||||
from app.clients.cloudwatch.aws_cloudwatch import AwsCloudwatchClient
|
||||
from app.config import QueueNames
|
||||
from app.config import QueueNames, TaskNames
|
||||
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_status_to_error,
|
||||
dao_update_job,
|
||||
find_jobs_with_missing_rows,
|
||||
find_missing_row_for_job,
|
||||
)
|
||||
from app.dao.notifications_dao import (
|
||||
dao_batch_insert_notifications,
|
||||
dao_close_out_delivery_receipts,
|
||||
dao_update_delivery_receipts,
|
||||
dao_old_letters_with_created_status,
|
||||
dao_precompiled_letters_still_pending_virus_check,
|
||||
is_delivery_slow_for_providers,
|
||||
letters_missing_from_sending_bucket,
|
||||
notifications_not_yet_sent,
|
||||
)
|
||||
from app.dao.provider_details_dao import (
|
||||
dao_adjust_provider_priority_back_to_resting_points,
|
||||
dao_reduce_sms_provider_priority,
|
||||
)
|
||||
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.enums import JobStatus, NotificationType
|
||||
from app.models import Job, Notification
|
||||
from app.letters.utils import generate_letter_pdf_filename
|
||||
from app.models import (
|
||||
EMAIL_TYPE,
|
||||
JOB_STATUS_ERROR,
|
||||
JOB_STATUS_IN_PROGRESS,
|
||||
JOB_STATUS_PENDING,
|
||||
SMS_TYPE,
|
||||
BroadcastMessage,
|
||||
BroadcastStatusType,
|
||||
Job,
|
||||
)
|
||||
from app.notifications.process_notifications import send_notification_to_queue
|
||||
from app.utils import utc_now
|
||||
from notifications_utils import aware_utcnow
|
||||
from notifications_utils.clients.zendesk.zendesk_client import NotifySupportTicket
|
||||
|
||||
MAX_NOTIFICATION_FAILS = 10000
|
||||
|
||||
|
||||
@notify_celery.task(name="run-scheduled-jobs")
|
||||
@@ -50,9 +65,7 @@ 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
|
||||
@@ -61,148 +74,228 @@ def run_scheduled_jobs():
|
||||
@notify_celery.task(name="delete-verify-codes")
|
||||
def delete_verify_codes():
|
||||
try:
|
||||
start = utc_now()
|
||||
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, utc_now(), 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 = utc_now()
|
||||
expired_invites = expire_invitations_created_more_than_two_days_ago()
|
||||
start = datetime.utcnow()
|
||||
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 {utc_now()} expired {expired_invites} invitations"
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
current_app.logger.exception("Failed to expire invitations")
|
||||
raise
|
||||
|
||||
try:
|
||||
start = utc_now()
|
||||
deleted_invites = delete_org_invitations_created_more_than_two_days_ago()
|
||||
current_app.logger.info(
|
||||
f"Delete job started {start} finished {utc_now()} 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
|
||||
|
||||
|
||||
@notify_celery.task(name="check-job-status")
|
||||
@notify_celery.task(name='switch-current-sms-provider-on-slow-delivery')
|
||||
def switch_current_sms_provider_on_slow_delivery():
|
||||
"""
|
||||
Reduce provider's priority if at least 30% of notifications took more than four minutes to be delivered
|
||||
in the last ten minutes. If both providers are slow, don't do anything. If we changed the providers in the
|
||||
last ten minutes, then don't update them again either.
|
||||
"""
|
||||
slow_delivery_notifications = is_delivery_slow_for_providers(
|
||||
threshold=0.3,
|
||||
created_at=datetime.utcnow() - timedelta(minutes=10),
|
||||
delivery_time=timedelta(minutes=4),
|
||||
)
|
||||
|
||||
# only adjust if some values are true and some are false - ie, don't adjust if all providers are fast or
|
||||
# all providers are slow
|
||||
if len(set(slow_delivery_notifications.values())) != 1:
|
||||
for provider_name, is_slow in slow_delivery_notifications.items():
|
||||
if is_slow:
|
||||
current_app.logger.warning('Slow delivery notifications detected for provider {}'.format(provider_name))
|
||||
dao_reduce_sms_provider_priority(provider_name, time_threshold=timedelta(minutes=10))
|
||||
|
||||
|
||||
@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')
|
||||
def check_job_status():
|
||||
"""
|
||||
every x minutes do this check
|
||||
select
|
||||
from jobs
|
||||
where job_status == 'in progress'
|
||||
and processing started some time ago
|
||||
and processing started between 30 and 35 minutes ago
|
||||
OR where the job_status == 'pending'
|
||||
and the job scheduled_for timestamp is some time ago.
|
||||
and the job scheduled_for timestamp is between 30 and 35 minutes ago.
|
||||
if any results then
|
||||
update the job_status to 'error'
|
||||
process the rows in the csv that are missing (in another task) just do the check here.
|
||||
"""
|
||||
START_MINUTES = 245
|
||||
END_MINUTES = 240
|
||||
end_minutes_ago = utc_now() - timedelta(minutes=END_MINUTES)
|
||||
start_minutes_ago = utc_now() - timedelta(minutes=START_MINUTES)
|
||||
thirty_minutes_ago = datetime.utcnow() - timedelta(minutes=30)
|
||||
thirty_five_minutes_ago = datetime.utcnow() - timedelta(minutes=35)
|
||||
|
||||
incomplete_in_progress_jobs = select(Job).where(
|
||||
Job.job_status == JobStatus.IN_PROGRESS,
|
||||
between(Job.processing_started, start_minutes_ago, end_minutes_ago),
|
||||
incomplete_in_progress_jobs = Job.query.filter(
|
||||
Job.job_status == JOB_STATUS_IN_PROGRESS,
|
||||
between(Job.processing_started, thirty_five_minutes_ago, thirty_minutes_ago)
|
||||
)
|
||||
incomplete_pending_jobs = select(Job).where(
|
||||
Job.job_status == JobStatus.PENDING,
|
||||
incomplete_pending_jobs = Job.query.filter(
|
||||
Job.job_status == JOB_STATUS_PENDING,
|
||||
Job.scheduled_for.isnot(None),
|
||||
between(Job.scheduled_for, start_minutes_ago, end_minutes_ago),
|
||||
)
|
||||
jobs_not_completed_after_allotted_time = union(
|
||||
incomplete_in_progress_jobs, incomplete_pending_jobs
|
||||
)
|
||||
jobs_not_completed_after_allotted_time = (
|
||||
jobs_not_completed_after_allotted_time.order_by(
|
||||
Job.processing_started, Job.scheduled_for
|
||||
)
|
||||
between(Job.scheduled_for, thirty_five_minutes_ago, thirty_minutes_ago)
|
||||
)
|
||||
|
||||
jobs_not_complete_after_allotted_time = db.session.execute(
|
||||
jobs_not_completed_after_allotted_time
|
||||
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_allotted_time:
|
||||
dao_update_job_status_to_error(job)
|
||||
for job in jobs_not_complete_after_30_minutes:
|
||||
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)
|
||||
|
||||
# if the letter has not be send after an hour, then create a zendesk ticket
|
||||
letters = letters_missing_from_sending_bucket(resend_created_notifications_older_than)
|
||||
|
||||
if len(letters) > 0:
|
||||
msg = "{} letters were created over an hour ago, " \
|
||||
"but do not have an updated_at timestamp or billable units. " \
|
||||
"\n Creating app.celery.letters_pdf_tasks.create_letters tasks to upload letter to S3 " \
|
||||
"and update notifications for the following notification ids: " \
|
||||
"\n {}".format(len(letters), [x.id for x in letters])
|
||||
|
||||
current_app.logger.info(msg)
|
||||
for letter in letters:
|
||||
get_pdf_for_templated_letter.apply_async([str(letter.id)], queue=QueueNames.CREATE_LETTERS_PDF)
|
||||
|
||||
|
||||
@notify_celery.task(name="check-for-missing-rows-in-completed-jobs")
|
||||
@notify_celery.task(name='check-if-letters-still-pending-virus-check')
|
||||
def check_if_letters_still_pending_virus_check():
|
||||
letters = []
|
||||
|
||||
for letter in dao_precompiled_letters_still_pending_virus_check():
|
||||
# find letter in the scan bucket
|
||||
filename = generate_letter_pdf_filename(
|
||||
letter.reference,
|
||||
letter.created_at,
|
||||
ignore_folder=True,
|
||||
postage=letter.postage
|
||||
)
|
||||
|
||||
if s3.file_exists(current_app.config['LETTERS_SCAN_BUCKET_NAME'], filename):
|
||||
current_app.logger.warning(
|
||||
f'Letter id {letter.id} got stuck in pending-virus-check. Sending off for scan again.'
|
||||
)
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.SCAN_FILE,
|
||||
kwargs={'filename': filename},
|
||||
queue=QueueNames.ANTIVIRUS,
|
||||
)
|
||||
else:
|
||||
letters.append(letter)
|
||||
|
||||
if len(letters) > 0:
|
||||
letter_ids = [(str(letter.id), letter.reference) for letter in letters]
|
||||
|
||||
msg = f"""{len(letters)} precompiled letters have been pending-virus-check for over 90 minutes.
|
||||
We couldn't find them in the scan bucket. We'll need to find out where the files are and kick them off
|
||||
again or move them to technical failure.
|
||||
|
||||
Notifications: {sorted(letter_ids)}"""
|
||||
|
||||
if current_app.config['NOTIFY_ENVIRONMENT'] in ['live', 'production', 'test']:
|
||||
ticket = NotifySupportTicket(
|
||||
subject=f"[{current_app.config['NOTIFY_ENVIRONMENT']}] Letters still pending virus check",
|
||||
message=msg,
|
||||
ticket_type=NotifySupportTicket.TYPE_INCIDENT,
|
||||
technical_ticket=True,
|
||||
ticket_categories=['notify_letters']
|
||||
)
|
||||
zendesk_client.send_ticket_to_zendesk(ticket)
|
||||
current_app.logger.error(msg)
|
||||
|
||||
|
||||
@notify_celery.task(name='check-if-letters-still-in-created')
|
||||
def check_if_letters_still_in_created():
|
||||
letters = dao_old_letters_with_created_status()
|
||||
|
||||
if len(letters) > 0:
|
||||
msg = "{} letters were created before 17.30 yesterday and still have 'created' status. " \
|
||||
"Follow runbook to resolve: " \
|
||||
"https://github.com/alphagov/notifications-manuals/wiki/Support-Runbook" \
|
||||
"#deal-with-Letters-still-in-created.".format(len(letters))
|
||||
|
||||
if current_app.config['NOTIFY_ENVIRONMENT'] in ['live', 'production', 'test']:
|
||||
ticket = NotifySupportTicket(
|
||||
subject=f"[{current_app.config['NOTIFY_ENVIRONMENT']}] Letters still in 'created' status",
|
||||
message=msg,
|
||||
ticket_type=NotifySupportTicket.TYPE_INCIDENT,
|
||||
technical_ticket=True,
|
||||
ticket_categories=['notify_letters']
|
||||
)
|
||||
zendesk_client.send_ticket_to_zendesk(ticket)
|
||||
current_app.logger.error(msg)
|
||||
|
||||
|
||||
@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(
|
||||
f"Processing missing row: {row_to_process.missing_row} for job: {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 = utc_now() - timedelta(days=1)
|
||||
end_date = utc_now()
|
||||
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(
|
||||
@@ -210,19 +303,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(
|
||||
@@ -232,121 +323,47 @@ 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)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, max_retries=7, default_retry_delay=3600, name="process-delivery-receipts"
|
||||
)
|
||||
def process_delivery_receipts(self):
|
||||
# If we need to check db settings do it here for convenience
|
||||
# current_app.logger.info(f"POOL SIZE {app.db.engine.pool.size()}")
|
||||
"""
|
||||
Every eight minutes or so (see config.py) we run this task, which searches the last ten
|
||||
minutes of logs for delivery receipts and batch updates the db with the results. The overlap
|
||||
is intentional. We don't mind re-updating things, it is better than losing data.
|
||||
@notify_celery.task(name='trigger-link-tests')
|
||||
def trigger_link_tests():
|
||||
if current_app.config['CBC_PROXY_ENABLED']:
|
||||
for cbc_name in current_app.config['ENABLED_CBCS']:
|
||||
trigger_link_test.apply_async(kwargs={'provider': cbc_name}, queue=QueueNames.BROADCASTS)
|
||||
|
||||
We also set this to retry with exponential backoff in the case of failure. The only way this would
|
||||
fail is if, for example the db went down, or redis filled causing the app to stop processing. But if
|
||||
it does fail, we need to go back over at some point when things are running again and process those results.
|
||||
"""
|
||||
try:
|
||||
batch_size = 1000 # in theory with postgresql this could be 10k to 20k?
|
||||
|
||||
cloudwatch = AwsCloudwatchClient()
|
||||
cloudwatch.init_app(current_app)
|
||||
start_time = aware_utcnow() - timedelta(minutes=3)
|
||||
end_time = aware_utcnow()
|
||||
delivered_receipts, failed_receipts = cloudwatch.check_delivery_receipts(
|
||||
start_time, end_time
|
||||
@notify_celery.task(name='auto-expire-broadcast-messages')
|
||||
def auto_expire_broadcast_messages():
|
||||
expired_broadcasts = BroadcastMessage.query.filter(
|
||||
BroadcastMessage.finishes_at <= datetime.now(),
|
||||
BroadcastMessage.status == BroadcastStatusType.BROADCASTING,
|
||||
).all()
|
||||
|
||||
for broadcast in expired_broadcasts:
|
||||
broadcast.status = BroadcastStatusType.COMPLETED
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if expired_broadcasts:
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.PUBLISH_GOVUK_ALERTS,
|
||||
queue=QueueNames.GOVUK_ALERTS
|
||||
)
|
||||
delivered_receipts = list(delivered_receipts)
|
||||
for i in range(0, len(delivered_receipts), batch_size):
|
||||
batch = delivered_receipts[i : i + batch_size]
|
||||
dao_update_delivery_receipts(batch, True)
|
||||
failed_receipts = list(failed_receipts)
|
||||
for i in range(0, len(failed_receipts), batch_size):
|
||||
batch = failed_receipts[i : i + batch_size]
|
||||
dao_update_delivery_receipts(batch, False)
|
||||
except Exception as ex:
|
||||
retry_count = self.request.retries
|
||||
wait_time = 3600 * 2**retry_count
|
||||
try:
|
||||
raise self.retry(ex=ex, countdown=wait_time)
|
||||
except self.MaxRetriesExceededError:
|
||||
current_app.logger.error(
|
||||
"Failed process delivery receipts after max retries"
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(
|
||||
bind=True, max_retries=2, default_retry_delay=3600, name="cleanup-delivery-receipts"
|
||||
)
|
||||
def cleanup_delivery_receipts(self):
|
||||
dao_close_out_delivery_receipts()
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="batch-insert-notifications")
|
||||
def batch_insert_notifications(self):
|
||||
batch = []
|
||||
|
||||
# TODO We probably need some way to clear the list if
|
||||
# things go haywire. A command?
|
||||
|
||||
# with redis_store.pipeline():
|
||||
# while redis_store.llen("message_queue") > 0:
|
||||
# redis_store.lpop("message_queue")
|
||||
# current_app.logger.info("EMPTY!")
|
||||
# return
|
||||
current_len = redis_store.llen("message_queue")
|
||||
with redis_store.pipeline():
|
||||
# since this list is being fed by other processes, just grab what is available when
|
||||
# this call is made and process that.
|
||||
|
||||
count = 0
|
||||
while count < current_len:
|
||||
count = count + 1
|
||||
notification_bytes = redis_store.lpop("message_queue")
|
||||
notification_dict = json.loads(notification_bytes.decode("utf-8"))
|
||||
notification_dict["status"] = notification_dict.pop("notification_status")
|
||||
if not notification_dict.get("created_at"):
|
||||
notification_dict["created_at"] = utc_now()
|
||||
elif isinstance(notification_dict["created_at"], list):
|
||||
notification_dict["created_at"] = notification_dict["created_at"][0]
|
||||
notification = Notification(**notification_dict)
|
||||
# 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 notification is None:
|
||||
continue
|
||||
if "verify_code" in str(notification.personalisation):
|
||||
pass
|
||||
else:
|
||||
batch.append(notification)
|
||||
try:
|
||||
dao_batch_insert_notifications(batch)
|
||||
except Exception:
|
||||
current_app.logger.exception("Notification batch insert failed")
|
||||
for n in batch:
|
||||
# Use 'created_at' as a TTL so we don't retry infinitely
|
||||
notification_time = n.created_at
|
||||
if isinstance(notification_time, str):
|
||||
notification_time = datetime.fromisoformat(n.created_at)
|
||||
if notification_time < utc_now() - timedelta(seconds=50):
|
||||
current_app.logger.warning(
|
||||
f"Abandoning stale data, could not write to db: {n.serialize_for_redis(n)}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
redis_store.rpush("message_queue", json.dumps(n.serialize_for_redis(n)))
|
||||
@notify_celery.task(name='remove-yesterdays-planned-tests-on-govuk-alerts')
|
||||
def remove_yesterdays_planned_tests_on_govuk_alerts():
|
||||
notify_celery.send_task(
|
||||
name=TaskNames.PUBLISH_GOVUK_ALERTS,
|
||||
queue=QueueNames.GOVUK_ALERTS
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -118,18 +114,10 @@ def create_delivery_status_callback_data(notification, service_callback_api):
|
||||
"notification_client_reference": notification.client_reference,
|
||||
"notification_to": notification.to,
|
||||
"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 +127,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),
|
||||
|
||||
@@ -1,98 +1,105 @@
|
||||
import json
|
||||
from time import sleep
|
||||
from collections import defaultdict, namedtuple
|
||||
from datetime import datetime
|
||||
|
||||
from celery.signals import task_postrun
|
||||
from flask import current_app
|
||||
from notifications_utils.insensitive_dict import InsensitiveDict
|
||||
from notifications_utils.postal_address import PostalAddress
|
||||
from notifications_utils.recipients import RecipientCSV
|
||||
from notifications_utils.timezones import convert_utc_to_bst
|
||||
from requests import HTTPError, RequestException, request
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
|
||||
from app import create_uuid, encryption, notify_celery
|
||||
from app import create_random_identifier, create_uuid, encryption, notify_celery
|
||||
from app.aws import s3
|
||||
from app.celery import provider_tasks
|
||||
from app.config import Config, QueueNames
|
||||
from app.celery import letters_pdf_tasks, provider_tasks, research_mode_tasks
|
||||
from app.config import QueueNames
|
||||
from app.dao.daily_sorted_letter_dao import (
|
||||
dao_create_or_update_daily_sorted_letter,
|
||||
)
|
||||
from app.dao.inbound_sms_dao import dao_get_inbound_sms_by_id
|
||||
from app.dao.jobs_dao import dao_get_job_by_id, dao_update_job
|
||||
from app.dao.notifications_dao import (
|
||||
dao_get_last_notification_added_for_job_id,
|
||||
dao_get_notification_or_history_by_reference,
|
||||
dao_update_notifications_by_reference,
|
||||
get_notification_by_id,
|
||||
update_notification_status_by_reference,
|
||||
)
|
||||
from app.dao.provider_details_dao import (
|
||||
get_provider_details_by_notification_type,
|
||||
)
|
||||
from app.dao.returned_letters_dao import insert_or_update_returned_letters
|
||||
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.errors import TotalRequestsError
|
||||
from app.notifications.process_notifications import (
|
||||
get_notification,
|
||||
persist_notification,
|
||||
from app.exceptions import DVLAException, NotificationTechnicalFailureException
|
||||
from app.models import (
|
||||
DVLA_RESPONSE_STATUS_SENT,
|
||||
EMAIL_TYPE,
|
||||
JOB_STATUS_CANCELLED,
|
||||
JOB_STATUS_FINISHED,
|
||||
JOB_STATUS_IN_PROGRESS,
|
||||
JOB_STATUS_PENDING,
|
||||
KEY_TYPE_NORMAL,
|
||||
LETTER_TYPE,
|
||||
NOTIFICATION_CREATED,
|
||||
NOTIFICATION_DELIVERED,
|
||||
NOTIFICATION_RETURNED_LETTER,
|
||||
NOTIFICATION_SENDING,
|
||||
NOTIFICATION_TECHNICAL_FAILURE,
|
||||
NOTIFICATION_TEMPORARY_FAILURE,
|
||||
SMS_TYPE,
|
||||
DailySortedLetter,
|
||||
)
|
||||
from app.notifications.validators import check_service_over_total_message_limit
|
||||
from app.notifications.process_notifications import persist_notification
|
||||
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, hilite, utc_now
|
||||
from notifications_utils.recipients import RecipientCSV
|
||||
from app.utils import DATETIME_FORMAT, get_reference_from_personalisation
|
||||
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 = utc_now()
|
||||
start = datetime.utcnow()
|
||||
job = dao_get_job_by_id(job_id)
|
||||
current_app.logger.info(
|
||||
f"Starting process-job task for job id {job_id} with status: {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(
|
||||
f"Job {job_id} has been cancelled, service {service.id} 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(
|
||||
f"Starting job {job_id} processing {job.notification_count} notifications"
|
||||
)
|
||||
current_app.logger.info("Starting job {} processing {} notifications".format(job_id, job.notification_count))
|
||||
|
||||
# notify-api-1495 we are going to sleep periodically to give other
|
||||
# jobs running at the same time a chance to get some of their messages
|
||||
# sent. Sleep for 1 second after every 3 sends, which gives us throughput
|
||||
# of about 3600*3 per hour and would keep the queue clear assuming only one sender.
|
||||
# It will also hopefully eliminate throttling when we send messages which we are
|
||||
# currently seeing.
|
||||
count = 0
|
||||
for row in recipient_csv.get_rows():
|
||||
process_row(row, template, job, service, sender_id=sender_id)
|
||||
count = count + 1
|
||||
if count % 3 == 0:
|
||||
sleep(1)
|
||||
|
||||
# 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 = utc_now()
|
||||
finished = datetime.utcnow()
|
||||
job.processing_finished = finished
|
||||
dao_update_job(job)
|
||||
|
||||
@@ -102,9 +109,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)
|
||||
)
|
||||
|
||||
|
||||
@@ -112,40 +117,36 @@ 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,
|
||||
LETTER_TYPE: save_letter
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -153,141 +154,100 @@ def process_row(row, template, job, service, sender_id=None):
|
||||
encrypted,
|
||||
),
|
||||
task_kwargs,
|
||||
queue=QueueNames.DATABASE,
|
||||
expires=Config.DEFAULT_REDIS_EXPIRE_TIME,
|
||||
queue=QueueNames.DATABASE if not service.research_mode else QueueNames.RESEARCH_MODE
|
||||
)
|
||||
return notification_id
|
||||
|
||||
|
||||
# TODO
|
||||
# Originally this was checking a daily limit
|
||||
# It is now checking an overall limit (annual?) for the free tier
|
||||
# Is there any limit for the paid tier?
|
||||
# Assuming the limit is annual, is it calendar year, fiscal year, MOU year?
|
||||
# Do we need a command to run to clear the redis value, or should it happen automatically?
|
||||
def __total_sending_limits_for_job_exceeded(service, job, job_id):
|
||||
print(hilite("ENTER __total_sending_limits_for_job_exceeded"))
|
||||
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"
|
||||
job.processing_finished = utc_now()
|
||||
except TooManyRequestsError:
|
||||
job.job_status = 'sending limits exceeded'
|
||||
job.processing_finished = datetime.utcnow()
|
||||
dao_update_job(job)
|
||||
current_app.logger.exception(
|
||||
"Job {} size {} error. Total sending limits {} exceeded".format(
|
||||
job_id, job.notification_count, service.total_message_limit
|
||||
),
|
||||
current_app.logger.info(
|
||||
"Job {} size {} error. Sending limits {} exceeded".format(
|
||||
job_id, job.notification_count, service.message_limit)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@task_postrun.connect
|
||||
def log_task_ejection(sender=None, task_id=None, **kwargs):
|
||||
current_app.logger.info(
|
||||
f"Task {task_id} ({sender.name if sender else 'unknown_task'}) has been completed and removed"
|
||||
)
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="save-sms", max_retries=2, default_retry_delay=600)
|
||||
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."""
|
||||
@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):
|
||||
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):
|
||||
current_app.logger.info(
|
||||
hilite(
|
||||
f"service not allowed to send for job_id {notification.get('job', None)}, aborting"
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
current_app.logger.debug(f"SMS {notification_id} failed as restricted service")
|
||||
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
|
||||
|
||||
try:
|
||||
saved_notification = persist_notification(
|
||||
template_id=notification["template"],
|
||||
template_version=notification["template_version"],
|
||||
recipient=notification["to"],
|
||||
service=service,
|
||||
personalisation=notification.get("personalisation"),
|
||||
notification_type=NotificationType.SMS,
|
||||
api_key_id=None,
|
||||
key_type=KeyType.NORMAL,
|
||||
created_at=utc_now(),
|
||||
created_by_id=created_by_id,
|
||||
job_id=notification.get("job", None),
|
||||
job_row_number=notification.get("row_number", None),
|
||||
notification_id=notification_id,
|
||||
reply_to_text=reply_to_text,
|
||||
)
|
||||
except IntegrityError:
|
||||
current_app.logger.warning(
|
||||
f"{NotificationType.SMS}: {notification_id} already exists."
|
||||
)
|
||||
# If we don't have the return statement here, we will fall through and end
|
||||
# up retrying because IntegrityError is a subclass of SQLAlchemyError
|
||||
return
|
||||
|
||||
# Kick off sns process in provider_tasks.py
|
||||
sn = saved_notification
|
||||
current_app.logger.info(
|
||||
hilite(
|
||||
f"Deliver sms for job_id: {sn.job_id} row_number: {sn.job_row_number}"
|
||||
)
|
||||
saved_notification = persist_notification(
|
||||
template_id=notification['template'],
|
||||
template_version=notification['template_version'],
|
||||
recipient=notification['to'],
|
||||
service=service,
|
||||
personalisation=notification.get('personalisation'),
|
||||
notification_type=SMS_TYPE,
|
||||
api_key_id=None,
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
created_at=datetime.utcnow(),
|
||||
job_id=notification.get('job', None),
|
||||
job_row_number=notification.get('row_number', None),
|
||||
notification_id=notification_id,
|
||||
reply_to_text=reply_to_text
|
||||
)
|
||||
|
||||
provider_tasks.deliver_sms.apply_async(
|
||||
[str(saved_notification.id)], queue=QueueNames.SEND_SMS, countdown=60
|
||||
[str(saved_notification.id)],
|
||||
queue=QueueNames.SEND_SMS if not service.research_mode else QueueNames.RESEARCH_MODE
|
||||
)
|
||||
|
||||
current_app.logger.debug(
|
||||
f"SMS {saved_notification.id} created at {saved_notification.created_at} "
|
||||
f"for job {notification.get('job', None)}"
|
||||
"SMS {} created at {} for job {}".format(
|
||||
saved_notification.id,
|
||||
saved_notification.created_at,
|
||||
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:
|
||||
@@ -295,155 +255,325 @@ 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
|
||||
original_notification = get_notification(notification_id)
|
||||
|
||||
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,
|
||||
created_at=utc_now(),
|
||||
job_id=notification.get("job", None),
|
||||
job_row_number=notification.get("row_number", None),
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
created_at=datetime.utcnow(),
|
||||
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
|
||||
)
|
||||
# we only want to send once
|
||||
if original_notification is None:
|
||||
provider_tasks.deliver_email.apply_async(
|
||||
[str(saved_notification.id)], queue=QueueNames.SEND_EMAIL
|
||||
)
|
||||
|
||||
current_app.logger.debug(
|
||||
"Email {} created at {}".format(
|
||||
saved_notification.id, saved_notification.created_at
|
||||
)
|
||||
provider_tasks.deliver_email.apply_async(
|
||||
[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))
|
||||
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=2, default_retry_delay=600
|
||||
)
|
||||
@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
|
||||
)
|
||||
|
||||
original_notification = get_notification(notification["id"])
|
||||
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']
|
||||
)
|
||||
# Only get here if save to the db was successful (i.e. first time)
|
||||
if original_notification is None:
|
||||
provider_task.apply_async([notification["id"]], queue=q)
|
||||
current_app.logger.debug(
|
||||
f"{notification['id']} has been persisted and sent to delivery queue."
|
||||
)
|
||||
|
||||
except IntegrityError:
|
||||
current_app.logger.warning(
|
||||
f"{notification['notification_type']} {notification['id']} already exists."
|
||||
q = q if not service.research_mode else QueueNames.RESEARCH_MODE
|
||||
provider_task.apply_async(
|
||||
[notification['id']],
|
||||
queue=q
|
||||
)
|
||||
# If we don't have the return statement here, we will fall through and end
|
||||
# up retrying because IntegrityError is a subclass of SQLAlchemyError
|
||||
return
|
||||
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.")
|
||||
|
||||
except SQLAlchemyError:
|
||||
|
||||
try:
|
||||
self.retry(queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME)
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
current_app.logger.exception(
|
||||
f"Max retry failed Failed to persist notification {notification['id']}",
|
||||
current_app.logger.error(f"Max retry failed Failed to persist notification {notification['id']}")
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="save-letter", max_retries=5, default_retry_delay=300)
|
||||
def save_letter(
|
||||
self,
|
||||
service_id,
|
||||
notification_id,
|
||||
encrypted_notification,
|
||||
):
|
||||
notification = encryption.decrypt(encrypted_notification)
|
||||
|
||||
postal_address = PostalAddress.from_personalisation(
|
||||
InsensitiveDict(notification['personalisation'])
|
||||
)
|
||||
|
||||
service = SerialisedService.from_id(service_id)
|
||||
template = SerialisedTemplate.from_id_and_service_id(
|
||||
notification['template'],
|
||||
service_id=service.id,
|
||||
version=notification['template_version'],
|
||||
)
|
||||
|
||||
try:
|
||||
# if we don't want to actually send the letter, then start it off in SENDING so we don't pick it up
|
||||
status = NOTIFICATION_CREATED if not service.research_mode else NOTIFICATION_SENDING
|
||||
|
||||
saved_notification = persist_notification(
|
||||
template_id=notification['template'],
|
||||
template_version=notification['template_version'],
|
||||
postage=postal_address.postage if postal_address.international else template.postage,
|
||||
recipient=postal_address.normalised,
|
||||
service=service,
|
||||
personalisation=notification['personalisation'],
|
||||
notification_type=LETTER_TYPE,
|
||||
api_key_id=None,
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
created_at=datetime.utcnow(),
|
||||
job_id=notification['job'],
|
||||
job_row_number=notification['row_number'],
|
||||
notification_id=notification_id,
|
||||
reference=create_random_identifier(),
|
||||
client_reference=get_reference_from_personalisation(notification['personalisation']),
|
||||
reply_to_text=template.reply_to_text,
|
||||
status=status
|
||||
)
|
||||
|
||||
if not service.research_mode:
|
||||
letters_pdf_tasks.get_pdf_for_templated_letter.apply_async(
|
||||
[str(saved_notification.id)],
|
||||
queue=QueueNames.CREATE_LETTERS_PDF
|
||||
)
|
||||
elif current_app.config['NOTIFY_ENVIRONMENT'] in ['preview', 'development']:
|
||||
research_mode_tasks.create_fake_letter_response_file.apply_async(
|
||||
(saved_notification.reference,),
|
||||
queue=QueueNames.RESEARCH_MODE
|
||||
)
|
||||
else:
|
||||
update_notification_status_by_reference(saved_notification.reference, 'delivered')
|
||||
|
||||
current_app.logger.debug("Letter {} 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='update-letter-notifications-to-sent')
|
||||
def update_letter_notifications_to_sent_to_dvla(self, notification_references):
|
||||
# This task will be called by the FTP app to update notifications as sent to DVLA
|
||||
provider = get_provider_details_by_notification_type(LETTER_TYPE)[0]
|
||||
|
||||
updated_count, _ = dao_update_notifications_by_reference(
|
||||
notification_references,
|
||||
{
|
||||
'status': NOTIFICATION_SENDING,
|
||||
'sent_by': provider.identifier,
|
||||
'sent_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
|
||||
current_app.logger.info("Updated {} letter notifications to sending".format(updated_count))
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name='update-letter-notifications-to-error')
|
||||
def update_letter_notifications_to_error(self, notification_references):
|
||||
# This task will be called by the FTP app to update notifications as sent to DVLA
|
||||
|
||||
updated_count, _ = dao_update_notifications_by_reference(
|
||||
notification_references,
|
||||
{
|
||||
'status': NOTIFICATION_TECHNICAL_FAILURE,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
message = "Updated {} letter notifications to technical-failure with references {}".format(
|
||||
updated_count, notification_references
|
||||
)
|
||||
raise NotificationTechnicalFailureException(message)
|
||||
|
||||
|
||||
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,
|
||||
expires=Config.DEFAULT_REDIS_EXPIRE_TIME,
|
||||
)
|
||||
task.retry(queue=QueueNames.RETRY, exc=exc)
|
||||
except task.MaxRetriesExceededError:
|
||||
current_app.logger.exception("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='update-letter-notifications-statuses')
|
||||
def update_letter_notifications_statuses(self, filename):
|
||||
notification_updates = parse_dvla_file(filename)
|
||||
|
||||
temporary_failures = []
|
||||
|
||||
for update in notification_updates:
|
||||
check_billable_units(update)
|
||||
update_letter_notification(filename, temporary_failures, update)
|
||||
if temporary_failures:
|
||||
# This will alert Notify that DVLA was unable to deliver the letters, we need to investigate
|
||||
message = "DVLA response file: {filename} has failed letters with notification.reference {failures}" \
|
||||
.format(filename=filename, failures=temporary_failures)
|
||||
raise DVLAException(message)
|
||||
|
||||
|
||||
@notify_celery.task(bind=True, name="record-daily-sorted-counts")
|
||||
def record_daily_sorted_counts(self, filename):
|
||||
sorted_letter_counts = defaultdict(int)
|
||||
notification_updates = parse_dvla_file(filename)
|
||||
for update in notification_updates:
|
||||
sorted_letter_counts[update.cost_threshold.lower()] += 1
|
||||
|
||||
unknown_status = sorted_letter_counts.keys() - {'unsorted', 'sorted'}
|
||||
if unknown_status:
|
||||
message = 'DVLA response file: {} contains unknown Sorted status {}'.format(
|
||||
filename, unknown_status.__repr__()
|
||||
)
|
||||
raise DVLAException(message)
|
||||
|
||||
billing_date = get_billing_date_in_bst_from_filename(filename)
|
||||
persist_daily_sorted_letter_counts(day=billing_date,
|
||||
file_name=filename,
|
||||
sorted_letter_counts=sorted_letter_counts)
|
||||
|
||||
|
||||
def parse_dvla_file(filename):
|
||||
bucket_location = '{}-ftp'.format(current_app.config['NOTIFY_EMAIL_DOMAIN'])
|
||||
response_file_content = s3.get_s3_file(bucket_location, filename)
|
||||
|
||||
try:
|
||||
return process_updates_from_file(response_file_content)
|
||||
except TypeError:
|
||||
raise DVLAException('DVLA response file: {} has an invalid format'.format(filename))
|
||||
|
||||
|
||||
def get_billing_date_in_bst_from_filename(filename):
|
||||
# exclude seconds from the date since we don't need it. We got a date ending in 60 second - which is not valid.
|
||||
datetime_string = filename.split('-')[1][:-2]
|
||||
datetime_obj = datetime.strptime(datetime_string, '%Y%m%d%H%M')
|
||||
return convert_utc_to_bst(datetime_obj).date()
|
||||
|
||||
|
||||
def persist_daily_sorted_letter_counts(day, file_name, sorted_letter_counts):
|
||||
daily_letter_count = DailySortedLetter(
|
||||
billing_day=day,
|
||||
file_name=file_name,
|
||||
unsorted_count=sorted_letter_counts['unsorted'],
|
||||
sorted_count=sorted_letter_counts['sorted']
|
||||
)
|
||||
dao_create_or_update_daily_sorted_letter(daily_letter_count)
|
||||
|
||||
|
||||
def process_updates_from_file(response_file):
|
||||
NotificationUpdate = namedtuple('NotificationUpdate', ['reference', 'status', 'page_count', 'cost_threshold'])
|
||||
notification_updates = [NotificationUpdate(*line.split('|')) for line in response_file.splitlines()]
|
||||
return notification_updates
|
||||
|
||||
|
||||
def update_letter_notification(filename, temporary_failures, update):
|
||||
if update.status == DVLA_RESPONSE_STATUS_SENT:
|
||||
status = NOTIFICATION_DELIVERED
|
||||
else:
|
||||
status = NOTIFICATION_TEMPORARY_FAILURE
|
||||
temporary_failures.append(update.reference)
|
||||
|
||||
updated_count, _ = dao_update_notifications_by_reference(
|
||||
references=[update.reference],
|
||||
update_dict={"status": status,
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
)
|
||||
|
||||
if not updated_count:
|
||||
msg = "Update letter notification file {filename} failed: notification either not found " \
|
||||
"or already updated from delivered. Status {status} for notification reference {reference}".format(
|
||||
filename=filename, status=status, reference=update.reference)
|
||||
current_app.logger.info(msg)
|
||||
|
||||
|
||||
def check_billable_units(notification_update):
|
||||
notification = dao_get_notification_or_history_by_reference(notification_update.reference)
|
||||
|
||||
if int(notification_update.page_count) != notification.billable_units:
|
||||
msg = 'Notification with id {} has {} billable_units but DVLA says page count is {}'.format(
|
||||
notification.id, notification.billable_units, notification_update.page_count)
|
||||
try:
|
||||
raise DVLAException(msg)
|
||||
except DVLAException:
|
||||
current_app.logger.exception(msg)
|
||||
|
||||
|
||||
@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:
|
||||
@@ -452,61 +582,44 @@ 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, expires=Config.DEFAULT_REDIS_EXPIRE_TIME
|
||||
)
|
||||
self.retry(queue=QueueNames.RETRY)
|
||||
except self.MaxRetriesExceededError:
|
||||
current_app.logger.exception(
|
||||
"Retry: send_inbound_sms_to_service has retried the max number of"
|
||||
+ f"times for service: {service_id} and inbound_sms {inbound_sms_id}"
|
||||
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}"
|
||||
)
|
||||
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="regenerate-job-cache")
|
||||
def regenerate_job_cache():
|
||||
s3.get_s3_files()
|
||||
|
||||
|
||||
@notify_celery.task(name="clean-job-cache")
|
||||
def clean_job_cache():
|
||||
s3.clean_cache()
|
||||
|
||||
|
||||
@notify_celery.task(name="delete-old-s3-objects")
|
||||
def delete_old_s3_objects():
|
||||
s3.cleanup_old_s3_objects()
|
||||
|
||||
|
||||
@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.processing_started = utc_now()
|
||||
job.job_status = JOB_STATUS_IN_PROGRESS
|
||||
job.processing_started = datetime.utcnow()
|
||||
dao_update_job(job)
|
||||
|
||||
current_app.logger.info("Resuming Job(s) {}".format(job_ids))
|
||||
@@ -524,16 +637,28 @@ 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:
|
||||
process_row(row, template, job, job.service, sender_id=sender_id)
|
||||
|
||||
job_complete(job, resumed=True)
|
||||
|
||||
|
||||
@notify_celery.task(name='process-returned-letters-list')
|
||||
def process_returned_letters_list(notification_references):
|
||||
updated, updated_history = dao_update_notifications_by_reference(
|
||||
notification_references,
|
||||
{"status": NOTIFICATION_RETURNED_LETTER}
|
||||
)
|
||||
|
||||
insert_or_update_returned_letters(notification_references)
|
||||
|
||||
current_app.logger.info(
|
||||
"Updated {} letter notifications ({} history notifications, from {} references) to returned-letter".format(
|
||||
updated, updated_history, len(notification_references)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,38 +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,
|
||||
max_pool_connections=50, # This should be equal or greater than our celery concurrency
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
@@ -53,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']
|
||||
|
||||
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)
|
||||
|
||||
299
app/clients/cbc_proxy.py
Normal file
299
app/clients/cbc_proxy.py
Normal file
@@ -0,0 +1,299 @@
|
||||
import json
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import boto3
|
||||
import botocore
|
||||
from flask import current_app
|
||||
from notifications_utils.template import non_gsm_characters
|
||||
from sqlalchemy.schema import Sequence
|
||||
|
||||
from app.config import BroadcastProvider
|
||||
from app.utils import DATETIME_FORMAT, format_sequential_number
|
||||
|
||||
# The variable names in this file have specific meaning in a CAP message
|
||||
#
|
||||
# identifier is a unique field for each CAP message
|
||||
#
|
||||
# headline is a field which we are not sure if we will use
|
||||
#
|
||||
# description is the body of the message
|
||||
|
||||
# areas is a list of dicts, with the following items
|
||||
# * description is a string which populates the areaDesc field
|
||||
# * polygon is a list of lat/long pairs
|
||||
#
|
||||
# previous_provider_messages is a list of previous events (models.py::BroadcastProviderMessage)
|
||||
# ie a Cancel message would have a unique event but have the event of
|
||||
# the preceeding Alert message in the previous_provider_messages field
|
||||
|
||||
|
||||
class CBCProxyRetryableException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CBCProxyClient:
|
||||
_lambda_client = None
|
||||
|
||||
def init_app(self, app):
|
||||
if app.config.get('CBC_PROXY_ENABLED'):
|
||||
self._lambda_client = boto3.client(
|
||||
'lambda',
|
||||
region_name='eu-west-2',
|
||||
aws_access_key_id=app.config['CBC_PROXY_AWS_ACCESS_KEY_ID'],
|
||||
aws_secret_access_key=app.config['CBC_PROXY_AWS_SECRET_ACCESS_KEY'],
|
||||
)
|
||||
|
||||
def get_proxy(self, provider):
|
||||
proxy_classes = {
|
||||
BroadcastProvider.EE: CBCProxyEE,
|
||||
BroadcastProvider.THREE: CBCProxyThree,
|
||||
BroadcastProvider.O2: CBCProxyO2,
|
||||
BroadcastProvider.VODAFONE: CBCProxyVodafone,
|
||||
}
|
||||
return proxy_classes[provider](self._lambda_client)
|
||||
|
||||
|
||||
class CBCProxyClientBase(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def lambda_name(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def failover_lambda_name(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def LANGUAGE_ENGLISH(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def LANGUAGE_WELSH(self):
|
||||
pass
|
||||
|
||||
def __init__(self, lambda_client):
|
||||
self._lambda_client = lambda_client
|
||||
|
||||
def send_link_test(self):
|
||||
self._send_link_test(self.lambda_name)
|
||||
self._send_link_test(self.failover_lambda_name)
|
||||
|
||||
def _send_link_test(
|
||||
self,
|
||||
lambda_name,
|
||||
): pass
|
||||
|
||||
def create_and_send_broadcast(
|
||||
self, identifier, headline, description, areas, sent, expires, channel, message_number=None
|
||||
):
|
||||
pass
|
||||
|
||||
# We have not implementated updating a broadcast
|
||||
def update_and_send_broadcast(
|
||||
self,
|
||||
identifier, previous_provider_messages, headline, description, areas,
|
||||
sent, expires, channel, message_number=None
|
||||
):
|
||||
pass
|
||||
|
||||
def cancel_broadcast(
|
||||
self,
|
||||
identifier, previous_provider_messages, headline, description, areas,
|
||||
sent, expires, message_number=None
|
||||
):
|
||||
pass
|
||||
|
||||
def _invoke_lambda_with_failover(self, payload):
|
||||
result = self._invoke_lambda(self.lambda_name, payload)
|
||||
|
||||
if not result:
|
||||
failover_result = self._invoke_lambda(self.failover_lambda_name, payload)
|
||||
if not failover_result:
|
||||
raise CBCProxyRetryableException(
|
||||
f'Lambda failed for both {self.lambda_name} and {self.failover_lambda_name}'
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _invoke_lambda(self, lambda_name, payload):
|
||||
payload_bytes = bytes(json.dumps(payload), encoding='utf8')
|
||||
try:
|
||||
current_app.logger.info(
|
||||
f"Calling lambda {lambda_name} with payload {str(payload)[:1000]}"
|
||||
)
|
||||
|
||||
result = self._lambda_client.invoke(
|
||||
FunctionName=lambda_name,
|
||||
InvocationType='RequestResponse',
|
||||
Payload=payload_bytes,
|
||||
)
|
||||
except botocore.exceptions.ClientError:
|
||||
current_app.logger.exception(f'Boto ClientError calling lambda {lambda_name}')
|
||||
success = False
|
||||
return success
|
||||
|
||||
if result['StatusCode'] > 299:
|
||||
current_app.logger.info(
|
||||
f"Error calling lambda {lambda_name} with status code { result['StatusCode']}, {result.get('Payload')}"
|
||||
)
|
||||
success = False
|
||||
|
||||
elif 'FunctionError' in result:
|
||||
current_app.logger.info(
|
||||
f"Error calling lambda {lambda_name} with function error { result['Payload'].read() }"
|
||||
)
|
||||
success = False
|
||||
|
||||
else:
|
||||
success = True
|
||||
|
||||
return success
|
||||
|
||||
def infer_language_from(self, content):
|
||||
if non_gsm_characters(content):
|
||||
return self.LANGUAGE_WELSH
|
||||
return self.LANGUAGE_ENGLISH
|
||||
|
||||
|
||||
class CBCProxyOne2ManyClient(CBCProxyClientBase):
|
||||
LANGUAGE_ENGLISH = 'en-GB'
|
||||
LANGUAGE_WELSH = 'cy-GB'
|
||||
|
||||
def _send_link_test(
|
||||
self,
|
||||
lambda_name,
|
||||
):
|
||||
"""
|
||||
link test - open up a connection to a specific provider, and send them an xml payload with a <msgType> of
|
||||
test.
|
||||
"""
|
||||
payload = {
|
||||
'message_type': 'test',
|
||||
'identifier': str(uuid.uuid4()),
|
||||
'message_format': 'cap'
|
||||
}
|
||||
|
||||
self._invoke_lambda(lambda_name=lambda_name, payload=payload)
|
||||
|
||||
def create_and_send_broadcast(
|
||||
self, identifier, headline, description, areas, sent, expires, channel, message_number=None
|
||||
):
|
||||
payload = {
|
||||
'message_type': 'alert',
|
||||
'identifier': identifier,
|
||||
'message_format': 'cap',
|
||||
'headline': headline,
|
||||
'description': description,
|
||||
'areas': areas,
|
||||
'sent': sent,
|
||||
'expires': expires,
|
||||
'language': self.infer_language_from(description),
|
||||
'channel': channel,
|
||||
}
|
||||
self._invoke_lambda_with_failover(payload=payload)
|
||||
|
||||
def cancel_broadcast(
|
||||
self,
|
||||
identifier, previous_provider_messages,
|
||||
sent, message_number=None
|
||||
):
|
||||
payload = {
|
||||
'message_type': 'cancel',
|
||||
'identifier': identifier,
|
||||
'message_format': 'cap',
|
||||
"references": [
|
||||
{
|
||||
"message_id": str(message.id),
|
||||
"sent": message.created_at.strftime(DATETIME_FORMAT)
|
||||
} for message in previous_provider_messages
|
||||
],
|
||||
'sent': sent,
|
||||
}
|
||||
self._invoke_lambda_with_failover(payload=payload)
|
||||
|
||||
|
||||
class CBCProxyEE(CBCProxyOne2ManyClient):
|
||||
lambda_name = 'ee-1-proxy'
|
||||
failover_lambda_name = 'ee-2-proxy'
|
||||
|
||||
|
||||
class CBCProxyThree(CBCProxyOne2ManyClient):
|
||||
lambda_name = 'three-1-proxy'
|
||||
failover_lambda_name = 'three-2-proxy'
|
||||
|
||||
|
||||
class CBCProxyO2(CBCProxyOne2ManyClient):
|
||||
lambda_name = 'o2-1-proxy'
|
||||
failover_lambda_name = 'o2-2-proxy'
|
||||
|
||||
|
||||
class CBCProxyVodafone(CBCProxyClientBase):
|
||||
lambda_name = 'vodafone-1-proxy'
|
||||
failover_lambda_name = 'vodafone-2-proxy'
|
||||
|
||||
LANGUAGE_ENGLISH = 'English'
|
||||
LANGUAGE_WELSH = 'Welsh'
|
||||
|
||||
def _send_link_test(
|
||||
self,
|
||||
lambda_name,
|
||||
):
|
||||
"""
|
||||
link test - open up a connection to a specific provider, and send them an xml payload with a <msgType> of
|
||||
test.
|
||||
"""
|
||||
from app import db
|
||||
sequence = Sequence('broadcast_provider_message_number_seq')
|
||||
sequential_number = db.session.connection().execute(sequence)
|
||||
formatted_seq_number = format_sequential_number(sequential_number)
|
||||
|
||||
payload = {
|
||||
'message_type': 'test',
|
||||
'identifier': str(uuid.uuid4()),
|
||||
'message_number': formatted_seq_number,
|
||||
'message_format': 'ibag'
|
||||
}
|
||||
|
||||
self._invoke_lambda(lambda_name=lambda_name, payload=payload)
|
||||
|
||||
def create_and_send_broadcast(
|
||||
self, identifier, message_number, headline, description, areas, sent, expires, channel
|
||||
):
|
||||
payload = {
|
||||
'message_type': 'alert',
|
||||
'identifier': identifier,
|
||||
'message_number': message_number,
|
||||
'message_format': 'ibag',
|
||||
'headline': headline,
|
||||
'description': description,
|
||||
'areas': areas,
|
||||
'sent': sent,
|
||||
'expires': expires,
|
||||
'language': self.infer_language_from(description),
|
||||
'channel': channel,
|
||||
}
|
||||
self._invoke_lambda_with_failover(payload=payload)
|
||||
|
||||
def cancel_broadcast(
|
||||
self, identifier, previous_provider_messages, sent, message_number
|
||||
):
|
||||
|
||||
payload = {
|
||||
'message_type': 'cancel',
|
||||
'identifier': identifier,
|
||||
'message_number': message_number,
|
||||
'message_format': 'ibag',
|
||||
"references": [
|
||||
{
|
||||
"message_id": str(message.id),
|
||||
"message_number": format_sequential_number(message.message_number),
|
||||
"sent": message.created_at.strftime(DATETIME_FORMAT)
|
||||
} for message in previous_provider_messages
|
||||
],
|
||||
'sent': sent,
|
||||
}
|
||||
self._invoke_lambda_with_failover(payload=payload)
|
||||
@@ -1,203 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from boto3 import client
|
||||
from flask import current_app
|
||||
|
||||
from app.clients import AWS_CLIENT_CONFIG, Client
|
||||
from app.cloudfoundry_config import cloud_config
|
||||
|
||||
|
||||
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, log_group_name, start, end):
|
||||
# Check all cloudwatch logs from the time the notification was sent (currently 5 minutes previously) until now
|
||||
next_token = None
|
||||
all_log_events = []
|
||||
|
||||
while True:
|
||||
if next_token:
|
||||
response = self._client.filter_log_events(
|
||||
logGroupName=log_group_name,
|
||||
nextToken=next_token,
|
||||
startTime=int(start.timestamp() * 1000),
|
||||
endTime=int(end.timestamp() * 1000),
|
||||
)
|
||||
else:
|
||||
response = self._client.filter_log_events(
|
||||
logGroupName=log_group_name,
|
||||
startTime=int(start.timestamp() * 1000),
|
||||
endTime=int(end.timestamp() * 1000),
|
||||
)
|
||||
log_events = response.get("events", [])
|
||||
all_log_events.extend(log_events)
|
||||
next_token = response.get("nextToken")
|
||||
if not next_token:
|
||||
break
|
||||
return all_log_events
|
||||
|
||||
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 _extract_account_number(self, ses_domain_arn):
|
||||
account_number = ses_domain_arn.split(":")
|
||||
return account_number
|
||||
|
||||
def event_to_db_format(self, event):
|
||||
|
||||
# massage the data into the form the db expects. When we switch
|
||||
# from filter_log_events to log insights this will be convenient
|
||||
if isinstance(event, str):
|
||||
event = json.loads(event)
|
||||
|
||||
# Don't trust AWS to always send the same JSON structure back
|
||||
# However, if we don't get message_id and status we might as well blow up
|
||||
# because it's pointless to continue
|
||||
phone_carrier = self._aws_value_or_default(event, "delivery", "phoneCarrier")
|
||||
provider_response = self._aws_value_or_default(
|
||||
event, "delivery", "providerResponse"
|
||||
)
|
||||
message_cost = self._aws_value_or_default(event, "delivery", "priceInUSD")
|
||||
if message_cost is None or message_cost == "":
|
||||
message_cost = 0.0
|
||||
else:
|
||||
message_cost = float(message_cost)
|
||||
|
||||
my_timestamp = self._aws_value_or_default(event, "notification", "timestamp")
|
||||
return {
|
||||
"notification.messageId": event["notification"]["messageId"],
|
||||
"status": event["status"],
|
||||
"delivery.phoneCarrier": phone_carrier,
|
||||
"delivery.providerResponse": provider_response,
|
||||
"@timestamp": my_timestamp,
|
||||
"delivery.priceInUSD": message_cost,
|
||||
}
|
||||
|
||||
# Here is an example of how to get the events with log insights
|
||||
# def do_log_insights():
|
||||
# query = """
|
||||
# fields @timestamp, status, message, recipient
|
||||
# | filter status = "DELIVERED"
|
||||
# | sort @timestamp asc
|
||||
# """
|
||||
# temp_client = boto3.client(
|
||||
# "logs",
|
||||
# region_name="us-gov-west-1",
|
||||
# aws_access_key_id=AWS_ACCESS_KEY_ID,
|
||||
# aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
||||
# config=AWS_CLIENT_CONFIG,
|
||||
# )
|
||||
# start = utc_now()
|
||||
# end = utc_now - timedelta(hours=1)
|
||||
# response = temp_client.start_query(
|
||||
# logGroupName = LOG_GROUP_NAME_DELIVERED,
|
||||
# startTime = int(start.timestamp()),
|
||||
# endTime= int(end.timestamp()),
|
||||
# queryString = query
|
||||
|
||||
# )
|
||||
# query_id = response['queryId']
|
||||
# while True:
|
||||
# result = temp_client.get_query_results(queryId=query_id)
|
||||
# if result['status'] == 'Complete':
|
||||
# break
|
||||
# time.sleep(1)
|
||||
|
||||
# delivery_receipts = []
|
||||
# for log in result['results']:
|
||||
# receipt = {field['field']: field['value'] for field in log}
|
||||
# delivery_receipts.append(receipt)
|
||||
# print(receipt)
|
||||
|
||||
# print(len(delivery_receipts))
|
||||
|
||||
# In the long run we want to use Log Insights because it is more efficient
|
||||
# that filter_log_events. But we are blocked by a permissions issue in the broker.
|
||||
# So for now, use filter_log_events and grab all log_events over a 10 minute interval,
|
||||
# and run this on a schedule.
|
||||
def check_delivery_receipts(self, start, end):
|
||||
region = cloud_config.sns_region
|
||||
account_number = self._extract_account_number(cloud_config.ses_domain_arn)
|
||||
log_group_name = f"sns/{region}/{account_number[4]}/DirectPublishToPhoneNumber"
|
||||
delivered_event_set = self._get_receipts(log_group_name, start, end)
|
||||
current_app.logger.info(
|
||||
(f"Delivered message count: {len(delivered_event_set)}")
|
||||
)
|
||||
log_group_name = (
|
||||
f"sns/{region}/{account_number[4]}/DirectPublishToPhoneNumber/Failure"
|
||||
)
|
||||
failed_event_set = self._get_receipts(log_group_name, start, end)
|
||||
current_app.logger.info((f"Failed message count: {len(failed_event_set)}"))
|
||||
|
||||
return delivered_event_set, failed_event_set
|
||||
|
||||
def _get_receipts(self, log_group_name, start, end):
|
||||
event_set = set()
|
||||
all_events = self._get_log(log_group_name, start, end)
|
||||
for event in all_events:
|
||||
try:
|
||||
actual_event = self.event_to_db_format(event["message"])
|
||||
event_set.add(json.dumps(actual_event))
|
||||
except Exception:
|
||||
current_app.logger.exception(
|
||||
f"Could not format delivery receipt {event} for db insert"
|
||||
)
|
||||
return event_set
|
||||
|
||||
def _aws_value_or_default(self, event, top_level, second_level):
|
||||
if event.get(top_level) is None or event[top_level].get(second_level) is None:
|
||||
my_var = ""
|
||||
else:
|
||||
my_var = event[top_level][second_level]
|
||||
|
||||
return my_var
|
||||
@@ -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,13 +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,
|
||||
},
|
||||
timeout=30,
|
||||
'document': file_contents,
|
||||
'is_csv': is_csv or False,
|
||||
}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
@@ -42,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.')
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
from time import monotonic
|
||||
|
||||
import boto3
|
||||
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 +52,83 @@ class AwsSesClientThrottlingSendRateException(AwsSesClientException):
|
||||
|
||||
|
||||
class AwsSesClient(EmailClient):
|
||||
"""
|
||||
'''
|
||||
Amazon SES email client.
|
||||
"""
|
||||
'''
|
||||
|
||||
def init_app(self, *args, **kwargs):
|
||||
self._client = client(
|
||||
"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,
|
||||
)
|
||||
def init_app(self, region, statsd_client, *args, **kwargs):
|
||||
self._client = boto3.client('ses', region_name=region)
|
||||
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, region, 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,53 +3,49 @@ import json
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
from notifications_utils.timezones import convert_utc_to_bst
|
||||
|
||||
|
||||
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,
|
||||
timeout=30,
|
||||
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
|
||||
@@ -59,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_bst(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
|
||||
@@ -76,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,66 +0,0 @@
|
||||
from boto3 import client
|
||||
from botocore.exceptions import ClientError
|
||||
from flask import current_app
|
||||
|
||||
from app.clients import AWS_CLIENT_CONFIG, Client
|
||||
from app.cloudfoundry_config import cloud_config
|
||||
from app.utils import hilite
|
||||
|
||||
|
||||
class AwsPinpointClient(Client):
|
||||
|
||||
def init_app(self, current_app, *args, **kwargs):
|
||||
self._client = client(
|
||||
"pinpoint",
|
||||
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,
|
||||
)
|
||||
|
||||
super(Client, self).__init__(*args, **kwargs)
|
||||
self.current_app = current_app
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "pinpoint"
|
||||
|
||||
def validate_phone_number(self, country_code, phone_number):
|
||||
try:
|
||||
response = self._client.phone_number_validate(
|
||||
NumberValidateRequest={
|
||||
"IsoCountryCode": country_code,
|
||||
"PhoneNumber": phone_number,
|
||||
}
|
||||
)
|
||||
|
||||
# TODO right now this will only print with AWS simulated numbers,
|
||||
# but remove this when that changes
|
||||
current_app.logger.info(hilite(response))
|
||||
except ClientError:
|
||||
current_app.logger.exception(
|
||||
"#notify-debug-validate-phone-number Could not validate with pinpoint"
|
||||
)
|
||||
|
||||
# TODO This is the structure of the response. When the phone validation
|
||||
# capability we want to offer is better defined (it may just be a question
|
||||
# of checking PhoneType -- i.e., landline or mobile) then do something with
|
||||
# this info.
|
||||
# {
|
||||
# 'NumberValidateResponse': {
|
||||
# 'Carrier': 'string',
|
||||
# 'City': 'string',
|
||||
# 'CleansedPhoneNumberE164': 'string',
|
||||
# 'CleansedPhoneNumberNational': 'string',
|
||||
# 'Country': 'string',
|
||||
# 'CountryCodeIso2': 'string',
|
||||
# 'CountryCodeNumeric': 'string',
|
||||
# 'County': 'string',
|
||||
# 'OriginalCountryCodeIso2': 'string',
|
||||
# 'OriginalPhoneNumber': 'string',
|
||||
# 'PhoneType': 'string',
|
||||
# 'PhoneTypeCode': 123,
|
||||
# 'Timezone': 'string',
|
||||
# 'ZipCode': 'string'
|
||||
# }
|
||||
# }
|
||||
@@ -1,34 +1,71 @@
|
||||
from abc import abstractmethod, abstractproperty
|
||||
from typing import final
|
||||
from time import monotonic
|
||||
|
||||
from app.clients import Client, ClientException
|
||||
|
||||
|
||||
class SmsClientResponseException(ClientException):
|
||||
"""
|
||||
'''
|
||||
Base Exception for SmsClientsResponses
|
||||
"""
|
||||
'''
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return f"Message {self.message}"
|
||||
return f"SMS client error ({self.message})"
|
||||
|
||||
|
||||
class SmsClient(Client):
|
||||
"""
|
||||
'''
|
||||
Base Sms client for sending smss.
|
||||
"""
|
||||
'''
|
||||
|
||||
@abstractmethod
|
||||
def send_sms(self, *args, **kwargs):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
def init_app(self, current_app, statsd_client):
|
||||
self.current_app = current_app
|
||||
self.statsd_client = statsd_client
|
||||
self.from_number = self.current_app.config.get('FROM_NUMBER')
|
||||
|
||||
@abstractproperty
|
||||
def record_outcome(self, success):
|
||||
log_message = "Provider request for {} {}".format(
|
||||
self.name,
|
||||
"succeeded" if success else "failed",
|
||||
)
|
||||
|
||||
if success:
|
||||
self.current_app.logger.info(log_message)
|
||||
self.statsd_client.incr(f"clients.{self.name}.success")
|
||||
else:
|
||||
self.statsd_client.incr(f"clients.{self.name}.error")
|
||||
self.current_app.logger.warning(log_message)
|
||||
|
||||
def send_sms(self, to, content, reference, international, sender):
|
||||
start_time = monotonic()
|
||||
|
||||
if sender is None:
|
||||
# temporary log to see if the following ternary is necessary
|
||||
# or if it's safe to remove it - keep for 1-2 weeks
|
||||
self.current_app.logger.warning(
|
||||
f"send_sms called with 'sender' of 'None' for {reference}"
|
||||
)
|
||||
|
||||
sender = self.from_number if sender is None else sender
|
||||
|
||||
try:
|
||||
response = self.try_send_sms(to, content, reference, international, sender)
|
||||
self.record_outcome(True)
|
||||
except SmsClientResponseException as e:
|
||||
self.record_outcome(False)
|
||||
raise e
|
||||
finally:
|
||||
elapsed_time = monotonic() - start_time
|
||||
self.statsd_client.timing(f"clients.{self.name}.request-time", elapsed_time)
|
||||
self.current_app.logger.info(f"{self.name} request for {reference} finished in {elapsed_time}")
|
||||
|
||||
return response
|
||||
|
||||
def try_send_sms(self, *args, **kwargs):
|
||||
raise NotImplementedError('TODO Need to implement.')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
|
||||
@final
|
||||
def get_name(self):
|
||||
return self.name
|
||||
raise NotImplementedError('TODO Need to implement.')
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
from time import monotonic
|
||||
|
||||
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):
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
super(SmsClient, self).__init__(*args, **kwargs)
|
||||
self.current_app = current_app
|
||||
self._valid_sender_regex = re.compile(r"^\+?\d{5,14}$")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "sns"
|
||||
|
||||
def _valid_sender_number(self, sender):
|
||||
return sender and re.match(self._valid_sender_regex, sender)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# See documentation
|
||||
# https://docs.aws.amazon.com/sns/latest/dg/sms_publish-to-phone.html#sms_publish_sdk
|
||||
attributes = {
|
||||
"AWS.SNS.SMS.SMSType": {
|
||||
"DataType": "String",
|
||||
"StringValue": "Transactional",
|
||||
}
|
||||
}
|
||||
|
||||
default_num = " ".join(self.current_app.config["AWS_US_TOLL_FREE_NUMBER"])
|
||||
if isinstance(sender, str):
|
||||
non_scrubbable = " ".join(sender)
|
||||
|
||||
self.current_app.logger.info(
|
||||
f"notify-debug-api-1385 sender {non_scrubbable} is a {type(sender)} \
|
||||
default is a {type(default_num)}"
|
||||
)
|
||||
else:
|
||||
self.current_app.logger.warning(
|
||||
f"notify-debug-api-1385 sender is type {type(sender)}!! {sender}"
|
||||
)
|
||||
if self._valid_sender_number(sender):
|
||||
self.current_app.logger.info(
|
||||
f"notify-debug-api-1385 use valid sender {non_scrubbable} instead of default {default_num}"
|
||||
)
|
||||
|
||||
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||
"DataType": "String",
|
||||
"StringValue": sender,
|
||||
}
|
||||
else:
|
||||
self.current_app.logger.info(
|
||||
f"notify-debug-api-1385 use default {default_num} instead of invalid sender"
|
||||
)
|
||||
|
||||
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||
"DataType": "String",
|
||||
"StringValue": self.current_app.config["AWS_US_TOLL_FREE_NUMBER"],
|
||||
}
|
||||
|
||||
try:
|
||||
start_time = monotonic()
|
||||
response = self._client.publish(
|
||||
PhoneNumber=to, Message=content, MessageAttributes=attributes
|
||||
)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
self.current_app.logger.exception("An error occurred sending sms")
|
||||
raise str(e)
|
||||
except Exception as e:
|
||||
self.current_app.logger.exception("An error occurred sending sms")
|
||||
raise str(e)
|
||||
finally:
|
||||
elapsed_time = monotonic() - start_time
|
||||
self.current_app.logger.info(
|
||||
"AWS SNS request finished in {}".format(elapsed_time)
|
||||
)
|
||||
return response["MessageId"]
|
||||
|
||||
if not matched:
|
||||
self.current_app.logger.error("No valid numbers found in {}".format(to))
|
||||
raise ValueError("No valid numbers found for SMS delivery")
|
||||
88
app/clients/sms/firetext.py
Normal file
88
app/clients/sms/firetext.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import RequestException, request
|
||||
|
||||
from app.clients.sms import SmsClient, SmsClientResponseException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Firetext will send a delivery receipt with three different status codes.
|
||||
# The `firetext_response` maps these codes to the notification statistics status and notification status.
|
||||
# If we get a pending (status = 2) delivery receipt followed by a declined (status = 1) delivery receipt we will set
|
||||
# the notification status to temporary-failure rather than permanent failure.
|
||||
# See the code in the notification_dao.update_notifications_status_by_id
|
||||
firetext_responses = {
|
||||
'0': 'delivered',
|
||||
'1': 'permanent-failure',
|
||||
'2': 'pending'
|
||||
}
|
||||
|
||||
firetext_codes = {
|
||||
# code '000' means 'No errors reported'
|
||||
'000': {'status': 'temporary-failure', 'reason': 'No error reported'},
|
||||
'101': {'status': 'permanent-failure', 'reason': 'Unknown Subscriber'},
|
||||
'102': {'status': 'temporary-failure', 'reason': 'Absent Subscriber'},
|
||||
'103': {'status': 'temporary-failure', 'reason': 'Subscriber Busy'},
|
||||
'104': {'status': 'temporary-failure', 'reason': 'No Subscriber Memory'},
|
||||
'201': {'status': 'permanent-failure', 'reason': 'Invalid Number'},
|
||||
'301': {'status': 'permanent-failure', 'reason': 'SMS Not Supported'},
|
||||
'302': {'status': 'temporary-failure', 'reason': 'SMS Not Supported'},
|
||||
'401': {'status': 'permanent-failure', 'reason': 'Message Rejected'},
|
||||
'900': {'status': 'temporary-failure', 'reason': 'Routing Error'},
|
||||
}
|
||||
|
||||
|
||||
def get_firetext_responses(status, detailed_status_code=None):
|
||||
detailed_status = firetext_codes[detailed_status_code]['reason'] if firetext_codes.get(
|
||||
detailed_status_code, None
|
||||
) else None
|
||||
return (firetext_responses[status], detailed_status)
|
||||
|
||||
|
||||
def get_message_status_and_reason_from_firetext_code(detailed_status_code):
|
||||
return firetext_codes[detailed_status_code]['status'], firetext_codes[detailed_status_code]['reason']
|
||||
|
||||
|
||||
class FiretextClient(SmsClient):
|
||||
'''
|
||||
FireText sms client.
|
||||
'''
|
||||
|
||||
def init_app(self, *args, **kwargs):
|
||||
super().init_app(*args, **kwargs)
|
||||
self.api_key = self.current_app.config.get('FIRETEXT_API_KEY')
|
||||
self.international_api_key = self.current_app.config.get('FIRETEXT_INTERNATIONAL_API_KEY')
|
||||
self.url = self.current_app.config.get('FIRETEXT_URL')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'firetext'
|
||||
|
||||
def try_send_sms(self, to, content, reference, international, sender):
|
||||
data = {
|
||||
"apiKey": self.international_api_key if international else self.api_key,
|
||||
"from": sender,
|
||||
"to": to.replace('+', ''),
|
||||
"message": content,
|
||||
"reference": reference
|
||||
}
|
||||
|
||||
try:
|
||||
response = request(
|
||||
"POST",
|
||||
self.url,
|
||||
data=data,
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
json.loads(response.text)
|
||||
if response.json()['code'] != 0:
|
||||
raise ValueError("Expected 'code' to be '0'")
|
||||
except (ValueError, AttributeError):
|
||||
raise SmsClientResponseException("Invalid response JSON")
|
||||
except RequestException:
|
||||
raise SmsClientResponseException("Request failed")
|
||||
|
||||
return response
|
||||
110
app/clients/sms/mmg.py
Normal file
110
app/clients/sms/mmg.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
|
||||
from requests import RequestException, request
|
||||
|
||||
from app.clients.sms import SmsClient, SmsClientResponseException
|
||||
|
||||
mmg_response_map = {
|
||||
'2': {'status': 'permanent-failure', 'substatus': {
|
||||
"1": "Number does not exist",
|
||||
"4": "Rejected by operator",
|
||||
"5": "Unidentified Subscriber",
|
||||
"9": "Undelivered",
|
||||
"11": "Service for Subscriber suspended",
|
||||
"12": "Illegal equipment",
|
||||
"2049": "Subscriber IMSI blacklisted",
|
||||
"2050": "Number blacklisted in do-not-disturb blacklist",
|
||||
"2052": "Destination number blacklisted",
|
||||
"2053": "Source address blacklisted"
|
||||
}},
|
||||
'3': {'status': 'delivered', 'substatus': {"2": "Delivered to operator", "5": "Delivered to handset"}},
|
||||
'4': {'status': 'temporary-failure', 'substatus': {
|
||||
"6": "Absent Subscriber",
|
||||
"8": "Roaming not allowed",
|
||||
"13": "SMS Not Supported",
|
||||
"15": "Expired",
|
||||
"27": "Absent Subscriber",
|
||||
"29": "Invalid delivery report",
|
||||
"32": "Delivery Failure",
|
||||
}},
|
||||
'5': {'status': 'permanent-failure', 'substatus': {
|
||||
"6": "Network out of coverage",
|
||||
"8": "Incorrect number prefix",
|
||||
"10": "Number on do-not-disturb service",
|
||||
"11": "Sender id not registered",
|
||||
"13": "Sender id blacklisted",
|
||||
"14": "Destination number blacklisted",
|
||||
"19": "Routing unavailable",
|
||||
"20": "Rejected by anti-flooding mechanism",
|
||||
"21": "System error", # it says to retry those messages or contact support
|
||||
"23": "Duplicate message id",
|
||||
"24": "Message formatted incorrectly",
|
||||
"25": "Message too long",
|
||||
"51": "Missing recipient value",
|
||||
"52": "Invalid destination",
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
def get_mmg_responses(status, detailed_status_code=None):
|
||||
return (mmg_response_map[status]["status"], mmg_response_map[status]["substatus"].get(detailed_status_code, None))
|
||||
|
||||
|
||||
class MMGClientResponseException(SmsClientResponseException):
|
||||
def __init__(self, response, exception):
|
||||
status_code = response.status_code if response is not None else 504
|
||||
text = response.text if response is not None else "Gateway Time-out"
|
||||
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.exception = exception
|
||||
|
||||
def __str__(self):
|
||||
return "Code {} text {} exception {}".format(self.status_code, self.text, str(self.exception))
|
||||
|
||||
|
||||
class MMGClient(SmsClient):
|
||||
'''
|
||||
MMG sms client
|
||||
'''
|
||||
|
||||
def init_app(self, *args, **kwargs):
|
||||
super().init_app(*args, **kwargs)
|
||||
self.api_key = self.current_app.config.get('MMG_API_KEY')
|
||||
self.mmg_url = self.current_app.config.get('MMG_URL')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'mmg'
|
||||
|
||||
def try_send_sms(self, to, content, reference, international, sender):
|
||||
data = {
|
||||
"reqType": "BULK",
|
||||
"MSISDN": to,
|
||||
"msg": content,
|
||||
"sender": sender,
|
||||
"cid": reference,
|
||||
"multi": True
|
||||
}
|
||||
|
||||
try:
|
||||
response = request(
|
||||
"POST",
|
||||
self.mmg_url,
|
||||
data=json.dumps(data),
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Basic {}'.format(self.api_key)
|
||||
},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
try:
|
||||
json.loads(response.text)
|
||||
except (ValueError, AttributeError):
|
||||
raise SmsClientResponseException("Invalid response JSON")
|
||||
except RequestException:
|
||||
raise SmsClientResponseException("Request failed")
|
||||
|
||||
return response
|
||||
52
app/clients/sms/reach.py
Normal file
52
app/clients/sms/reach.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
|
||||
from requests import RequestException, request
|
||||
|
||||
from app.clients.sms import SmsClient, SmsClientResponseException
|
||||
|
||||
|
||||
def get_reach_responses(status, detailed_status_code=None):
|
||||
if status == 'TODO-d':
|
||||
return ("delivered", "TODO: Delivered")
|
||||
elif status == 'TODO-tf':
|
||||
return ("temporary-failure", "TODO: Temporary failure")
|
||||
elif status == 'TODO-pf':
|
||||
return ("permanent-failure", "TODO: Permanent failure")
|
||||
else:
|
||||
raise KeyError
|
||||
|
||||
|
||||
class ReachClient(SmsClient):
|
||||
def init_app(self, *args, **kwargs):
|
||||
super().init_app(*args, **kwargs)
|
||||
self.url = self.current_app.config.get('REACH_URL')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'reach'
|
||||
|
||||
def try_send_sms(self, to, content, reference, international, sender):
|
||||
data = {
|
||||
# TODO
|
||||
}
|
||||
|
||||
try:
|
||||
response = request(
|
||||
"POST",
|
||||
self.url,
|
||||
data=json.dumps(data),
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
try:
|
||||
json.loads(response.text)
|
||||
except (ValueError, AttributeError):
|
||||
raise SmsClientResponseException("Invalid response JSON")
|
||||
except RequestException:
|
||||
raise SmsClientResponseException("Request failed")
|
||||
|
||||
return response
|
||||
@@ -1,110 +1,22 @@
|
||||
"""
|
||||
Extracts cloudfoundry config from its json and populates the environment variables that we would expect to be populated
|
||||
on local/aws boxes
|
||||
"""
|
||||
|
||||
import json
|
||||
from os import getenv
|
||||
import os
|
||||
|
||||
|
||||
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._empty_bucket_credentials = {
|
||||
"bucket": "",
|
||||
"access_key_id": "",
|
||||
"secret_access_key": "",
|
||||
"region": "",
|
||||
}
|
||||
|
||||
@property
|
||||
def database_url(self):
|
||||
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://")
|
||||
except KeyError:
|
||||
return getenv("REDIS_URL")
|
||||
|
||||
def s3_credentials(self, service_name):
|
||||
return self.s3_buckets.get(service_name) or self._empty_bucket_credentials
|
||||
|
||||
@property
|
||||
def ses_email_domain(self):
|
||||
try:
|
||||
domain_arn = self._ses_credentials("domain_arn")
|
||||
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
|
||||
|
||||
@property
|
||||
def ses_region(self):
|
||||
try:
|
||||
return self._ses_credentials("region")
|
||||
except KeyError:
|
||||
return getenv("SES_AWS_REGION", "us-west-1")
|
||||
|
||||
@property
|
||||
def ses_access_key(self):
|
||||
try:
|
||||
return self._ses_credentials("smtp_user")
|
||||
except KeyError:
|
||||
return getenv("SES_AWS_ACCESS_KEY_ID")
|
||||
|
||||
@property
|
||||
def ses_secret_key(self):
|
||||
try:
|
||||
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")
|
||||
|
||||
@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"),
|
||||
]
|
||||
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]
|
||||
def extract_cloudfoundry_config():
|
||||
vcap_services = json.loads(os.environ['VCAP_SERVICES'])
|
||||
set_config_env_vars(vcap_services)
|
||||
|
||||
|
||||
cloud_config = CloudfoundryConfig()
|
||||
def set_config_env_vars(vcap_services):
|
||||
# Postgres config
|
||||
os.environ['SQLALCHEMY_DATABASE_URI'] = vcap_services['postgres'][0]['credentials']['uri'].replace('postgres',
|
||||
'postgresql')
|
||||
|
||||
vcap_application = json.loads(os.environ['VCAP_APPLICATION'])
|
||||
os.environ['NOTIFY_ENVIRONMENT'] = vcap_application['space_name']
|
||||
os.environ['NOTIFY_LOG_PATH'] = '/home/vcap/logs/app.log'
|
||||
|
||||
1251
app/commands.py
1251
app/commands.py
File diff suppressed because it is too large
Load Diff
@@ -3,46 +3,44 @@ 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, utc_now
|
||||
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)
|
||||
|
||||
# If start and end date are not set, we are expecting today's stats.
|
||||
today = str(utc_now().date())
|
||||
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,10 +1,11 @@
|
||||
|
||||
complaint_count_request = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"description": "complaint count request schema",
|
||||
"type": "object",
|
||||
"title": "Complaint count request",
|
||||
"properties": {
|
||||
"start_date": {"type": ["string", "null"], "format": "date"},
|
||||
"end_date": {"type": ["string", "null"], "format": "date"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
731
app/config.py
731
app/config.py
@@ -1,431 +1,556 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from os import getenv, path
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from boto3 import Session
|
||||
from celery.schedules import crontab
|
||||
from kombu import Exchange, Queue
|
||||
|
||||
import notifications_utils
|
||||
from app.clients import AWS_CLIENT_CONFIG
|
||||
from app.cloudfoundry_config import cloud_config
|
||||
if os.environ.get('VCAP_SERVICES'):
|
||||
# on cloudfoundry, config is a json blob in VCAP_SERVICES - unpack it, and populate
|
||||
# standard environment variables from it
|
||||
from app.cloudfoundry_config import extract_cloudfoundry_config
|
||||
|
||||
extract_cloudfoundry_config()
|
||||
|
||||
|
||||
class QueueNames(object):
|
||||
PERIODIC = "periodic-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'
|
||||
PROCESS_FTP = 'process-ftp-tasks'
|
||||
CREATE_LETTERS_PDF = 'create-letters-pdf-tasks'
|
||||
CALLBACKS = 'service-callbacks'
|
||||
CALLBACKS_RETRY = 'service-callbacks-retry'
|
||||
LETTERS = 'letter-tasks'
|
||||
SMS_CALLBACKS = 'sms-callbacks'
|
||||
ANTIVIRUS = 'antivirus-tasks'
|
||||
SANITISE_LETTERS = 'sanitise-letter-tasks'
|
||||
SAVE_API_EMAIL = 'save-api-email-tasks'
|
||||
SAVE_API_SMS = 'save-api-sms-tasks'
|
||||
BROADCASTS = 'broadcast-tasks'
|
||||
GOVUK_ALERTS = 'govuk-alerts'
|
||||
|
||||
@staticmethod
|
||||
def all_queues():
|
||||
return [
|
||||
QueueNames.PRIORITY,
|
||||
QueueNames.PERIODIC,
|
||||
QueueNames.DATABASE,
|
||||
QueueNames.SEND_SMS,
|
||||
QueueNames.CHECK_SMS,
|
||||
QueueNames.SEND_EMAIL,
|
||||
QueueNames.RESEARCH_MODE,
|
||||
QueueNames.REPORTING,
|
||||
QueueNames.JOBS,
|
||||
QueueNames.RETRY,
|
||||
QueueNames.NOTIFY,
|
||||
QueueNames.CREATE_LETTERS_PDF,
|
||||
QueueNames.CALLBACKS,
|
||||
QueueNames.CALLBACKS_RETRY,
|
||||
QueueNames.LETTERS,
|
||||
QueueNames.SMS_CALLBACKS,
|
||||
QueueNames.SAVE_API_EMAIL,
|
||||
QueueNames.SAVE_API_SMS,
|
||||
QueueNames.BROADCASTS,
|
||||
]
|
||||
|
||||
|
||||
class BroadcastProvider:
|
||||
EE = 'ee'
|
||||
VODAFONE = 'vodafone'
|
||||
THREE = 'three'
|
||||
O2 = 'o2'
|
||||
|
||||
PROVIDERS = [EE, VODAFONE, THREE, O2]
|
||||
|
||||
|
||||
class TaskNames(object):
|
||||
PROCESS_INCOMPLETE_JOBS = "process-incomplete-jobs"
|
||||
SCAN_FILE = "scan-file"
|
||||
|
||||
|
||||
session = Session(
|
||||
aws_access_key_id=getenv("CSV_AWS_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=getenv("CSV_AWS_SECRET_ACCESS_KEY"),
|
||||
region_name=getenv("CSV_AWS_REGION"),
|
||||
)
|
||||
PROCESS_INCOMPLETE_JOBS = 'process-incomplete-jobs'
|
||||
ZIP_AND_SEND_LETTER_PDFS = 'zip-and-send-letter-pdfs'
|
||||
SCAN_FILE = 'scan-file'
|
||||
SANITISE_LETTER = 'sanitise-and-upload-letter'
|
||||
CREATE_PDF_FOR_TEMPLATED_LETTER = 'create-pdf-for-templated-letter'
|
||||
PUBLISH_GOVUK_ALERTS = 'publish-govuk-alerts'
|
||||
RECREATE_PDF_FOR_PRECOMPILED_LETTER = 'recreate-pdf-for-precompiled-letter'
|
||||
|
||||
|
||||
class Config(object):
|
||||
NOTIFY_APP_NAME = "api"
|
||||
DEFAULT_REDIS_EXPIRE_TIME = 4 * 24 * 60 * 60
|
||||
NOTIFY_ENVIRONMENT = getenv("NOTIFY_ENVIRONMENT", "development")
|
||||
# URL of admin app
|
||||
ADMIN_BASE_URL = getenv("ADMIN_BASE_URL", "http://localhost:6012")
|
||||
ADMIN_BASE_URL = os.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 = os.getenv('API_HOST_NAME')
|
||||
|
||||
# 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")
|
||||
INTERNAL_CLIENT_API_KEYS = json.loads(
|
||||
getenv(
|
||||
"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")
|
||||
ADMIN_CLIENT_ID = 'notify-admin'
|
||||
GOVUK_ALERTS_CLIENT_ID = 'govuk-alerts'
|
||||
|
||||
# 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", 40))
|
||||
SQLALCHEMY_POOL_TIMEOUT = 30
|
||||
SQLALCHEMY_POOL_RECYCLE = 300
|
||||
SQLALCHEMY_STATEMENT_TIMEOUT = 1200
|
||||
PAGE_SIZE = 20
|
||||
API_PAGE_SIZE = 250
|
||||
REDIS_URL = cloud_config.redis_url
|
||||
REDIS_ENABLED = getenv("REDIS_ENABLED", "1") == "1"
|
||||
INTERNAL_CLIENT_API_KEYS = json.loads(
|
||||
os.environ.get('INTERNAL_CLIENT_API_KEYS', '{}')
|
||||
)
|
||||
|
||||
# encyption secret/salt
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
DANGEROUS_SALT = os.getenv('DANGEROUS_SALT')
|
||||
|
||||
# DB conection string
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI')
|
||||
|
||||
# MMG API Key
|
||||
MMG_API_KEY = os.getenv('MMG_API_KEY')
|
||||
|
||||
# Firetext API Key
|
||||
FIRETEXT_API_KEY = os.getenv("FIRETEXT_API_KEY")
|
||||
FIRETEXT_INTERNATIONAL_API_KEY = os.getenv("FIRETEXT_INTERNATIONAL_API_KEY", "placeholder")
|
||||
|
||||
# Prefix to identify queues in SQS
|
||||
NOTIFICATION_QUEUE_PREFIX = os.getenv('NOTIFICATION_QUEUE_PREFIX')
|
||||
|
||||
# URL of redis instance
|
||||
REDIS_URL = os.getenv('REDIS_URL')
|
||||
REDIS_ENABLED = os.getenv('REDIS_ENABLED') == '1'
|
||||
EXPIRE_CACHE_TEN_MINUTES = 600
|
||||
EXPIRE_CACHE_EIGHT_DAYS = 8 * 24 * 60 * 60
|
||||
|
||||
# AWS Settings
|
||||
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
|
||||
)
|
||||
# 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,
|
||||
}
|
||||
|
||||
# Zendesk
|
||||
ZENDESK_API_KEY = getenv("ZENDESK_API_KEY")
|
||||
ZENDESK_API_KEY = os.environ.get('ZENDESK_API_KEY')
|
||||
|
||||
# Logging
|
||||
DEBUG = False
|
||||
NOTIFY_LOG_PATH = os.getenv('NOTIFY_LOG_PATH')
|
||||
|
||||
# Monitoring
|
||||
# Cronitor
|
||||
CRONITOR_ENABLED = False
|
||||
CRONITOR_KEYS = json.loads(getenv("CRONITOR_KEYS", "{}"))
|
||||
CRONITOR_KEYS = json.loads(os.environ.get('CRONITOR_KEYS', '{}'))
|
||||
|
||||
# Antivirus
|
||||
ANTIVIRUS_ENABLED = getenv("ANTIVIRUS_ENABLED", "1") == "1"
|
||||
ANTIVIRUS_ENABLED = True
|
||||
|
||||
SENDING_NOTIFICATIONS_TIMEOUT_PERIOD = 259200 # 3 days
|
||||
###########################
|
||||
# Default config values ###
|
||||
###########################
|
||||
|
||||
NOTIFY_ENVIRONMENT = 'development'
|
||||
AWS_REGION = 'eu-west-1'
|
||||
INVITATION_EXPIRATION_DAYS = 2
|
||||
TEST_MESSAGE_FILENAME = "Test message"
|
||||
ONE_OFF_MESSAGE_FILENAME = "Report"
|
||||
NOTIFY_APP_NAME = 'api'
|
||||
SQLALCHEMY_RECORD_QUERIES = False
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_POOL_SIZE = int(os.environ.get('SQLALCHEMY_POOL_SIZE', 5))
|
||||
SQLALCHEMY_POOL_TIMEOUT = 30
|
||||
SQLALCHEMY_POOL_RECYCLE = 300
|
||||
SQLALCHEMY_STATEMENT_TIMEOUT = 1200
|
||||
PAGE_SIZE = 50
|
||||
API_PAGE_SIZE = 250
|
||||
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/"
|
||||
# be careful increasing this size without being sure that we won't see slowness in pysftp
|
||||
MAX_LETTER_PDF_ZIP_FILESIZE = 40 * 1024 * 1024 # 40mb
|
||||
MAX_LETTER_PDF_COUNT_PER_ZIP = 500
|
||||
|
||||
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"
|
||||
CHECK_PROXY_HEADER = False
|
||||
|
||||
# these should always add up to 100%
|
||||
SMS_PROVIDER_RESTING_POINTS = {
|
||||
'mmg': 60,
|
||||
'firetext': 40
|
||||
}
|
||||
|
||||
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'
|
||||
BROADCAST_INVITATION_EMAIL_TEMPLATE_ID = '46152f7c-6901-41d5-8590-a5624d0d4359'
|
||||
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'
|
||||
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 = '07984404008'
|
||||
LETTERS_VOLUME_EMAIL_TEMPLATE_ID = '11fad854-fd38-4a7c-bd17-805fb13dfc12'
|
||||
# we only need real email in Live environment (production)
|
||||
DVLA_EMAIL_ADDRESSES = json.loads(getenv("DVLA_EMAIL_ADDRESSES", "[]"))
|
||||
|
||||
current_minute = (datetime.now().minute + 1) % 60
|
||||
|
||||
S3_CLIENT = session.client("s3")
|
||||
S3_RESOURCE = session.resource("s3", config=AWS_CLIENT_CONFIG)
|
||||
DVLA_EMAIL_ADDRESSES = json.loads(os.environ.get('DVLA_EMAIL_ADDRESSES', '[]'))
|
||||
|
||||
CELERY = {
|
||||
"worker_max_tasks_per_child": 500,
|
||||
"task_ignore_result": True,
|
||||
"result_persistent": False,
|
||||
"broker_url": REDIS_URL,
|
||||
"broker_transport_options": {
|
||||
"visibility_timeout": 310,
|
||||
'broker_url': 'sqs://',
|
||||
'broker_transport_options': {
|
||||
'region': AWS_REGION,
|
||||
'visibility_timeout': 310,
|
||||
'queue_name_prefix': NOTIFICATION_QUEUE_PREFIX,
|
||||
},
|
||||
"timezone": getenv("TIMEZONE", "UTC"),
|
||||
"imports": [
|
||||
"app.celery.tasks",
|
||||
"app.celery.scheduled_tasks",
|
||||
"app.celery.reporting_tasks",
|
||||
"app.celery.nightly_tasks",
|
||||
'timezone': 'Europe/London',
|
||||
'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}
|
||||
},
|
||||
"process-delivery-receipts": {
|
||||
"task": "process-delivery-receipts",
|
||||
"schedule": timedelta(minutes=2),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'delete-invitations': {
|
||||
'task': 'delete-invitations',
|
||||
'schedule': timedelta(minutes=66),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"cleanup-delivery-receipts": {
|
||||
"task": "cleanup-delivery-receipts",
|
||||
"schedule": timedelta(minutes=82),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'switch-current-sms-provider-on-slow-delivery': {
|
||||
'task': 'switch-current-sms-provider-on-slow-delivery',
|
||||
'schedule': crontab(), # Every minute
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"batch-insert-notifications": {
|
||||
"task": "batch-insert-notifications",
|
||||
"schedule": 10.0,
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'check-job-status': {
|
||||
'task': 'check-job-status',
|
||||
'schedule': crontab(),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"expire-or-delete-invitations": {
|
||||
"task": "expire-or-delete-invitations",
|
||||
"schedule": timedelta(minutes=66),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'tend-providers-back-to-middle': {
|
||||
'task': 'tend-providers-back-to-middle',
|
||||
'schedule': crontab(minute='*/5'),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"check-job-status": {
|
||||
"task": "check-job-status",
|
||||
"schedule": crontab(),
|
||||
"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}
|
||||
},
|
||||
"delete_old_s3_objects": {
|
||||
"task": "delete-old-s3-objects",
|
||||
"schedule": crontab(hour=7, minute=10),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'remove_sms_email_jobs': {
|
||||
'task': 'remove_sms_email_jobs',
|
||||
'schedule': crontab(hour=4, minute=0),
|
||||
'options': {'queue': QueueNames.PERIODIC},
|
||||
},
|
||||
"regenerate-job-cache": {
|
||||
"task": "regenerate-job-cache",
|
||||
"schedule": crontab(minute="*/30"),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'remove_letter_jobs': {
|
||||
'task': 'remove_letter_jobs',
|
||||
'schedule': crontab(hour=4, minute=20),
|
||||
# since we mark jobs as archived
|
||||
'options': {'queue': QueueNames.PERIODIC},
|
||||
},
|
||||
"regenerate-job-cache-on-startup": {
|
||||
"task": "regenerate-job-cache",
|
||||
"schedule": crontab(
|
||||
minute=current_minute
|
||||
), # Runs once at the next minute
|
||||
"options": {
|
||||
"queue": QueueNames.PERIODIC,
|
||||
"expires": 60,
|
||||
}, # Ensure it doesn't run if missed
|
||||
'check-if-letters-still-in-created': {
|
||||
'task': 'check-if-letters-still-in-created',
|
||||
'schedule': crontab(day_of_week='mon-fri', hour=7, minute=0),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"clean-job-cache": {
|
||||
"task": "clean-job-cache",
|
||||
"schedule": crontab(hour=2, minute=11),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'check-if-letters-still-pending-virus-check': {
|
||||
'task': 'check-if-letters-still-pending-virus-check',
|
||||
'schedule': crontab(day_of_week='mon-fri', hour='9,15', minute=0),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
"cleanup-unfinished-jobs": {
|
||||
"task": "cleanup-unfinished-jobs",
|
||||
"schedule": crontab(hour=4, minute=5),
|
||||
"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}
|
||||
},
|
||||
"remove_sms_email_jobs": {
|
||||
"task": "remove_sms_email_jobs",
|
||||
"schedule": crontab(hour=8, minute=0),
|
||||
"options": {"queue": QueueNames.PERIODIC},
|
||||
'raise-alert-if-letter-notifications-still-sending': {
|
||||
'task': 'raise-alert-if-letter-notifications-still-sending',
|
||||
'schedule': crontab(hour=17, minute=00),
|
||||
'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},
|
||||
# The collate-letter-pdf does assume it is called in an hour that BST does not make a
|
||||
# difference to the truncate date which translates to the filename to process
|
||||
'collate-letter-pdfs-to-be-sent': {
|
||||
'task': 'collate-letter-pdfs-to-be-sent',
|
||||
'schedule': crontab(hour=17, minute=50),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
},
|
||||
'raise-alert-if-no-letter-ack-file': {
|
||||
'task': 'raise-alert-if-no-letter-ack-file',
|
||||
'schedule': crontab(hour=23, minute=00),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
'trigger-link-tests': {
|
||||
'task': 'trigger-link-tests',
|
||||
'schedule': timedelta(minutes=15),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
'auto-expire-broadcast-messages': {
|
||||
'task': 'auto-expire-broadcast-messages',
|
||||
'schedule': timedelta(minutes=5),
|
||||
'options': {'queue': QueueNames.PERIODIC}
|
||||
},
|
||||
'remove-yesterdays-planned-tests-on-govuk-alerts': {
|
||||
'task': 'remove-yesterdays-planned-tests-on-govuk-alerts',
|
||||
'schedule': crontab(hour=00, minute=00),
|
||||
'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 os.getenv('CELERYD_PREFETCH_MULTIPLIER'):
|
||||
CELERY['worker_prefetch_multiplier'] = os.getenv('CELERYD_PREFETCH_MULTIPLIER')
|
||||
|
||||
FROM_NUMBER = "development"
|
||||
FROM_NUMBER = 'development'
|
||||
|
||||
STATSD_HOST = os.getenv('STATSD_HOST')
|
||||
STATSD_PORT = 8125
|
||||
STATSD_ENABLED = bool(STATSD_HOST)
|
||||
|
||||
SENDING_NOTIFICATIONS_TIMEOUT_PERIOD = 259200 # 3 days
|
||||
|
||||
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 = ('+447700900000', '+447700900111', '+447700900222')
|
||||
|
||||
FREE_SMS_TIER_FRAGMENT_COUNT = 250000
|
||||
|
||||
TOTAL_MESSAGE_LIMIT = 100000
|
||||
SMS_INBOUND_WHITELIST = json.loads(os.environ.get('SMS_INBOUND_WHITELIST', '[]'))
|
||||
FIRETEXT_INBOUND_SMS_AUTH = json.loads(os.environ.get('FIRETEXT_INBOUND_SMS_AUTH', '[]'))
|
||||
MMG_INBOUND_SMS_AUTH = json.loads(os.environ.get('MMG_INBOUND_SMS_AUTH', '[]'))
|
||||
MMG_INBOUND_SMS_USERNAME = json.loads(os.environ.get('MMG_INBOUND_SMS_USERNAME', '[]'))
|
||||
ROUTE_SECRET_KEY_1 = os.environ.get('ROUTE_SECRET_KEY_1', '')
|
||||
ROUTE_SECRET_KEY_2 = os.environ.get('ROUTE_SECRET_KEY_2', '')
|
||||
|
||||
DAILY_MESSAGE_LIMIT = notifications_utils.DAILY_MESSAGE_LIMIT
|
||||
HIGH_VOLUME_SERVICE = json.loads(os.environ.get('HIGH_VOLUME_SERVICE', '[]'))
|
||||
|
||||
HIGH_VOLUME_SERVICE = json.loads(getenv("HIGH_VOLUME_SERVICE", "[]"))
|
||||
TEMPLATE_PREVIEW_API_HOST = os.environ.get('TEMPLATE_PREVIEW_API_HOST', 'http://localhost:6013')
|
||||
TEMPLATE_PREVIEW_API_KEY = os.environ.get('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 = os.environ.get('DOCUMENT_DOWNLOAD_API_HOST', 'http://localhost:7000')
|
||||
DOCUMENT_DOWNLOAD_API_KEY = os.environ.get('DOCUMENT_DOWNLOAD_API_KEY', 'auth-token')
|
||||
|
||||
# these environment vars aren't defined in the manifest so to set them on paas use `cf set-env`
|
||||
MMG_URL = os.environ.get("MMG_URL", "https://api.mmg.co.uk/jsonv2a/api.php")
|
||||
FIRETEXT_URL = os.environ.get("FIRETEXT_URL", "https://www.firetext.co.uk/api/sendsms/json")
|
||||
REACH_URL = os.environ.get("REACH_URL", "TODO")
|
||||
SES_STUB_URL = os.environ.get("SES_STUB_URL")
|
||||
|
||||
AWS_REGION = 'eu-west-1'
|
||||
|
||||
CBC_PROXY_ENABLED = True
|
||||
CBC_PROXY_AWS_ACCESS_KEY_ID = os.environ.get('CBC_PROXY_AWS_ACCESS_KEY_ID', '')
|
||||
CBC_PROXY_AWS_SECRET_ACCESS_KEY = os.environ.get('CBC_PROXY_AWS_SECRET_ACCESS_KEY', '')
|
||||
|
||||
ENABLED_CBCS = {BroadcastProvider.EE, BroadcastProvider.THREE, BroadcastProvider.O2, BroadcastProvider.VODAFONE}
|
||||
|
||||
# as defined in api db migration 0331_add_broadcast_org.py
|
||||
BROADCAST_ORGANISATION_ID = '38e4bf69-93b0-445d-acee-53ea53fe02df'
|
||||
|
||||
|
||||
def _s3_credentials_from_env(bucket_prefix):
|
||||
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"),
|
||||
}
|
||||
|
||||
######################
|
||||
# Config overrides ###
|
||||
######################
|
||||
|
||||
class Development(Config):
|
||||
DEBUG = True
|
||||
NOTIFY_LOG_LEVEL = "DEBUG"
|
||||
SQLALCHEMY_ECHO = False
|
||||
DVLA_EMAIL_ADDRESSES = ["success@simulator.amazonses.com"]
|
||||
|
||||
# Buckets
|
||||
CSV_UPLOAD_BUCKET = _s3_credentials_from_env("CSV")
|
||||
CSV_UPLOAD_BUCKET_NAME = 'development-notifications-csv-upload'
|
||||
CONTACT_LIST_BUCKET_NAME = 'development-contact-list'
|
||||
TEST_LETTERS_BUCKET_NAME = 'development-test-letters'
|
||||
DVLA_RESPONSE_BUCKET_NAME = 'notify.tools-ftp'
|
||||
LETTERS_PDF_BUCKET_NAME = 'development-letters-pdf'
|
||||
LETTERS_SCAN_BUCKET_NAME = 'development-letters-scan'
|
||||
INVALID_PDF_BUCKET_NAME = 'development-letters-invalid-pdf'
|
||||
TRANSIENT_UPLOADED_LETTERS = 'development-transient-uploaded-letters'
|
||||
LETTER_SANITISE_BUCKET_NAME = 'development-letters-sanitise'
|
||||
|
||||
# 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"
|
||||
INTERNAL_CLIENT_API_KEYS = {
|
||||
Config.ADMIN_CLIENT_ID: ['dev-notify-secret-key'],
|
||||
Config.GOVUK_ALERTS_CLIENT_ID: ['govuk-alerts-secret-key']
|
||||
}
|
||||
|
||||
SECRET_KEY = 'dev-notify-secret-key'
|
||||
DANGEROUS_SALT = 'dev-notify-salt'
|
||||
|
||||
MMG_INBOUND_SMS_AUTH = ['testkey']
|
||||
MMG_INBOUND_SMS_USERNAME = ['username']
|
||||
|
||||
NOTIFY_ENVIRONMENT = 'development'
|
||||
NOTIFY_LOG_PATH = 'application.log'
|
||||
NOTIFY_EMAIL_DOMAIN = "notify.tools"
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI', 'postgresql://localhost/notification_api')
|
||||
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
ANTIVIRUS_ENABLED = os.getenv('ANTIVIRUS_ENABLED') == '1'
|
||||
|
||||
API_HOST_NAME = os.getenv('API_HOST_NAME', 'http://localhost:6011')
|
||||
API_RATE_LIMIT_ENABLED = True
|
||||
DVLA_EMAIL_ADDRESSES = ['success@simulator.amazonses.com']
|
||||
|
||||
CBC_PROXY_ENABLED = False
|
||||
|
||||
|
||||
class Test(Development):
|
||||
FROM_NUMBER = "testing"
|
||||
NOTIFY_EMAIL_DOMAIN = 'test.notify.com'
|
||||
FROM_NUMBER = 'testing'
|
||||
NOTIFY_ENVIRONMENT = 'test'
|
||||
TESTING = True
|
||||
ANTIVIRUS_ENABLED = True
|
||||
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',
|
||||
]
|
||||
|
||||
# this is overriden in CI
|
||||
SQLALCHEMY_DATABASE_URI = getenv("SQLALCHEMY_DATABASE_TEST_URI")
|
||||
CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload'
|
||||
CONTACT_LIST_BUCKET_NAME = 'test-contact-list'
|
||||
TEST_LETTERS_BUCKET_NAME = 'test-test-letters'
|
||||
DVLA_RESPONSE_BUCKET_NAME = 'test.notify.com-ftp'
|
||||
LETTERS_PDF_BUCKET_NAME = 'test-letters-pdf'
|
||||
LETTERS_SCAN_BUCKET_NAME = 'test-letters-scan'
|
||||
INVALID_PDF_BUCKET_NAME = 'test-letters-invalid-pdf'
|
||||
TRANSIENT_UPLOADED_LETTERS = 'test-transient-uploaded-letters'
|
||||
LETTER_SANITISE_BUCKET_NAME = 'test-letters-sanitise'
|
||||
|
||||
# this is overriden in jenkins and on cloudfoundry
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI', 'postgresql://localhost/test_notification_api')
|
||||
|
||||
CELERY = {
|
||||
**Config.CELERY,
|
||||
"broker_url": "you-forgot-to-mock-celery-in-your-tests://",
|
||||
'broker_url': 'you-forgot-to-mock-celery-in-your-tests://'
|
||||
}
|
||||
|
||||
ANTIVIRUS_ENABLED = True
|
||||
|
||||
class Production(Config):
|
||||
# buckets
|
||||
CSV_UPLOAD_BUCKET = cloud_config.s3_credentials(
|
||||
f"notify-api-csv-upload-bucket-{Config.NOTIFY_ENVIRONMENT}"
|
||||
)
|
||||
API_RATE_LIMIT_ENABLED = True
|
||||
API_HOST_NAME = "http://localhost:6011"
|
||||
|
||||
SMS_INBOUND_WHITELIST = ['203.0.113.195']
|
||||
FIRETEXT_INBOUND_SMS_AUTH = ['testkey']
|
||||
TEMPLATE_PREVIEW_API_HOST = 'http://localhost:9999'
|
||||
|
||||
MMG_URL = 'https://example.com/mmg'
|
||||
FIRETEXT_URL = 'https://example.com/firetext'
|
||||
REACH_URL = 'https://example.com/reach'
|
||||
|
||||
CBC_PROXY_ENABLED = True
|
||||
DVLA_EMAIL_ADDRESSES = ['success@simulator.amazonses.com', 'success+2@simulator.amazonses.com']
|
||||
|
||||
|
||||
class Preview(Config):
|
||||
NOTIFY_EMAIL_DOMAIN = 'notify.works'
|
||||
NOTIFY_ENVIRONMENT = 'preview'
|
||||
CSV_UPLOAD_BUCKET_NAME = 'preview-notifications-csv-upload'
|
||||
CONTACT_LIST_BUCKET_NAME = 'preview-contact-list'
|
||||
TEST_LETTERS_BUCKET_NAME = 'preview-test-letters'
|
||||
DVLA_RESPONSE_BUCKET_NAME = 'notify.works-ftp'
|
||||
LETTERS_PDF_BUCKET_NAME = 'preview-letters-pdf'
|
||||
LETTERS_SCAN_BUCKET_NAME = 'preview-letters-scan'
|
||||
INVALID_PDF_BUCKET_NAME = 'preview-letters-invalid-pdf'
|
||||
TRANSIENT_UPLOADED_LETTERS = 'preview-transient-uploaded-letters'
|
||||
LETTER_SANITISE_BUCKET_NAME = 'preview-letters-sanitise'
|
||||
FROM_NUMBER = 'preview'
|
||||
API_RATE_LIMIT_ENABLED = True
|
||||
CHECK_PROXY_HEADER = False
|
||||
|
||||
|
||||
class Staging(Config):
|
||||
NOTIFY_EMAIL_DOMAIN = 'staging-notify.works'
|
||||
NOTIFY_ENVIRONMENT = 'staging'
|
||||
CSV_UPLOAD_BUCKET_NAME = 'staging-notifications-csv-upload'
|
||||
CONTACT_LIST_BUCKET_NAME = 'staging-contact-list'
|
||||
TEST_LETTERS_BUCKET_NAME = 'staging-test-letters'
|
||||
DVLA_RESPONSE_BUCKET_NAME = 'staging-notify.works-ftp'
|
||||
LETTERS_PDF_BUCKET_NAME = 'staging-letters-pdf'
|
||||
LETTERS_SCAN_BUCKET_NAME = 'staging-letters-scan'
|
||||
INVALID_PDF_BUCKET_NAME = 'staging-letters-invalid-pdf'
|
||||
TRANSIENT_UPLOADED_LETTERS = 'staging-transient-uploaded-letters'
|
||||
LETTER_SANITISE_BUCKET_NAME = 'staging-letters-sanitise'
|
||||
FROM_NUMBER = 'stage'
|
||||
API_RATE_LIMIT_ENABLED = True
|
||||
CHECK_PROXY_HEADER = True
|
||||
|
||||
|
||||
class Live(Config):
|
||||
NOTIFY_EMAIL_DOMAIN = 'notifications.service.gov.uk'
|
||||
NOTIFY_ENVIRONMENT = 'live'
|
||||
CSV_UPLOAD_BUCKET_NAME = 'live-notifications-csv-upload'
|
||||
CONTACT_LIST_BUCKET_NAME = 'production-contact-list'
|
||||
TEST_LETTERS_BUCKET_NAME = 'production-test-letters'
|
||||
DVLA_RESPONSE_BUCKET_NAME = 'notifications.service.gov.uk-ftp'
|
||||
LETTERS_PDF_BUCKET_NAME = 'production-letters-pdf'
|
||||
LETTERS_SCAN_BUCKET_NAME = 'production-letters-scan'
|
||||
INVALID_PDF_BUCKET_NAME = 'production-letters-invalid-pdf'
|
||||
TRANSIENT_UPLOADED_LETTERS = 'production-transient-uploaded-letters'
|
||||
LETTER_SANITISE_BUCKET_NAME = 'production-letters-sanitise'
|
||||
FROM_NUMBER = 'GOVUK'
|
||||
API_RATE_LIMIT_ENABLED = True
|
||||
CHECK_PROXY_HEADER = True
|
||||
SES_STUB_URL = None
|
||||
|
||||
FROM_NUMBER = "Notify.gov"
|
||||
CRONITOR_ENABLED = True
|
||||
|
||||
|
||||
class Staging(Production):
|
||||
pass
|
||||
|
||||
|
||||
class Demo(Production):
|
||||
class CloudFoundryConfig(Config):
|
||||
pass
|
||||
|
||||
|
||||
configs = {
|
||||
"development": Development,
|
||||
"test": Test,
|
||||
"staging": Staging,
|
||||
"demo": Demo,
|
||||
"sandbox": Staging,
|
||||
"production": Production,
|
||||
'development': Development,
|
||||
'test': Test,
|
||||
'live': Live,
|
||||
'production': Live,
|
||||
'staging': Staging,
|
||||
'preview': Preview,
|
||||
}
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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.",
|
||||
"",
|
||||
"",
|
||||
"[Join Service](((url)))",
|
||||
"If you’re new to Notify.gov you will first be directed to Login.gov create an account with us.",
|
||||
"",
|
||||
"",
|
||||
"This invitation will stop working at midnight tomorrow. This is to keep ((service_name)) secure.",
|
||||
"",
|
||||
"",
|
||||
"Notify.gov uses Login.gov to allow you to sign in safely. Login.gov is a secure sign in service to help you access participating government agencies' digital touchpoints."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -7,55 +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"],
|
||||
},
|
||||
timeout=30,
|
||||
'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:
|
||||
|
||||
@@ -1,118 +1,117 @@
|
||||
from flask import current_app
|
||||
from sqlalchemy import select, update
|
||||
|
||||
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):
|
||||
stmt = (
|
||||
select(AnnualBilling)
|
||||
.where(
|
||||
AnnualBilling.service_id == service_id,
|
||||
)
|
||||
.order_by(AnnualBilling.financial_year_start)
|
||||
)
|
||||
return db.session.execute(stmt).scalars().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
|
||||
):
|
||||
stmt = (
|
||||
update(AnnualBilling)
|
||||
.where(
|
||||
AnnualBilling.service_id == service_id,
|
||||
AnnualBilling.financial_year_start > financial_year_start,
|
||||
)
|
||||
.values({"free_sms_fragment_limit": free_sms_fragment_limit})
|
||||
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}
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
stmt = select(AnnualBilling).where(
|
||||
AnnualBilling.service_id == service_id,
|
||||
AnnualBilling.financial_year_start == financial_year_start,
|
||||
)
|
||||
return db.session.execute(stmt).scalars().first()
|
||||
if not financial_year_start:
|
||||
financial_year_start = get_current_financial_year_start_year()
|
||||
|
||||
return AnnualBilling.query.filter_by(
|
||||
service_id=service_id,
|
||||
financial_year_start=financial_year_start
|
||||
).first()
|
||||
|
||||
|
||||
def dao_get_all_free_sms_fragment_limit(service_id):
|
||||
stmt = (
|
||||
select(AnnualBilling)
|
||||
.where(
|
||||
AnnualBilling.service_id == service_id,
|
||||
)
|
||||
.order_by(AnnualBilling.financial_year_start)
|
||||
)
|
||||
return db.session.execute(stmt).scalars().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: {
|
||||
'central': {
|
||||
2020: 250_000,
|
||||
2021: 150_000,
|
||||
2022: 40_000,
|
||||
},
|
||||
OrganizationType.STATE: {
|
||||
'local': {
|
||||
2020: 25_000,
|
||||
2021: 25_000,
|
||||
2022: 20_000,
|
||||
},
|
||||
'nhs_central': {
|
||||
2020: 250_000,
|
||||
2021: 150_000,
|
||||
2022: 40_000,
|
||||
},
|
||||
OrganizationType.OTHER: {
|
||||
2020: 250_000,
|
||||
2021: 150_000,
|
||||
2022: 40_000,
|
||||
'nhs_local': {
|
||||
2020: 25_000,
|
||||
2021: 25_000,
|
||||
2022: 20_000,
|
||||
},
|
||||
'nhs_gp': {
|
||||
2020: 25_000,
|
||||
2021: 10_000,
|
||||
2022: 10_000,
|
||||
},
|
||||
'emergency_service': {
|
||||
2020: 25_000,
|
||||
2021: 25_000,
|
||||
2022: 20_000,
|
||||
},
|
||||
'school_or_college': {
|
||||
2020: 25_000,
|
||||
2021: 10_000,
|
||||
2022: 10_000,
|
||||
},
|
||||
'other': {
|
||||
2020: 25_000,
|
||||
2021: 10_000,
|
||||
2022: 10_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
|
||||
)
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit, version_class
|
||||
from app.models import ApiKey
|
||||
from app.utils import utc_now
|
||||
|
||||
|
||||
@autocommit
|
||||
@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)
|
||||
|
||||
@@ -23,61 +20,26 @@ def save_model_api_key(api_key):
|
||||
@autocommit
|
||||
@version_class(ApiKey)
|
||||
def expire_api_key(service_id, api_key_id):
|
||||
api_key = (
|
||||
db.session.execute(
|
||||
select(ApiKey).where(
|
||||
ApiKey.id == api_key_id, ApiKey.service_id == service_id
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
api_key.expiry_date = utc_now()
|
||||
api_key = ApiKey.query.filter_by(id=api_key_id, service_id=service_id).one()
|
||||
api_key.expiry_date = datetime.utcnow()
|
||||
db.session.add(api_key)
|
||||
|
||||
|
||||
def get_model_api_keys(service_id, id=None):
|
||||
if id:
|
||||
return (
|
||||
db.session.execute(
|
||||
select(ApiKey).where(
|
||||
ApiKey.id == id,
|
||||
ApiKey.service_id == service_id,
|
||||
ApiKey.expiry_date == None, # noqa
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
seven_days_ago = utc_now() - timedelta(days=7)
|
||||
return (
|
||||
db.session.execute(
|
||||
select(ApiKey).where(
|
||||
or_(
|
||||
ApiKey.expiry_date == None, # noqa
|
||||
func.date(ApiKey.expiry_date) > seven_days_ago, # noqa
|
||||
),
|
||||
ApiKey.service_id == service_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
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, func.date(ApiKey.expiry_date) > seven_days_ago), # noqa
|
||||
ApiKey.service_id == service_id
|
||||
).all()
|
||||
|
||||
|
||||
def get_unsigned_secrets(service_id):
|
||||
"""
|
||||
This method can only be exposed to the Authentication of the api calls.
|
||||
"""
|
||||
api_keys = (
|
||||
db.session.execute(
|
||||
select(ApiKey).where(
|
||||
ApiKey.service_id == service_id, ApiKey.expiry_date == None # noqa
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
api_keys = ApiKey.query.filter_by(service_id=service_id, expiry_date=None).all()
|
||||
keys = [x.secret for x in api_keys]
|
||||
return keys
|
||||
|
||||
@@ -86,13 +48,5 @@ def get_unsigned_secret(key_id):
|
||||
"""
|
||||
This method can only be exposed to the Authentication of the api calls.
|
||||
"""
|
||||
api_key = (
|
||||
db.session.execute(
|
||||
select(ApiKey).where(
|
||||
ApiKey.id == key_id, ApiKey.expiry_date == None # noqa
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
api_key = ApiKey.query.filter_by(id=key_id, expiry_date=None).one()
|
||||
return api_key.secret
|
||||
|
||||
105
app/dao/broadcast_message_dao.py
Normal file
105
app/dao/broadcast_message_dao.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import (
|
||||
BroadcastEvent,
|
||||
BroadcastMessage,
|
||||
BroadcastProvider,
|
||||
BroadcastProviderMessage,
|
||||
BroadcastProviderMessageNumber,
|
||||
BroadcastProviderMessageStatus,
|
||||
BroadcastStatusType,
|
||||
ServiceBroadcastSettings,
|
||||
)
|
||||
|
||||
|
||||
def dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id):
|
||||
return BroadcastMessage.query.filter(
|
||||
BroadcastMessage.id == broadcast_message_id,
|
||||
BroadcastMessage.service_id == service_id
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_broadcast_message_by_references_and_service_id(references_to_original_broadcast, service_id):
|
||||
return BroadcastMessage.query.filter(
|
||||
BroadcastMessage.status.in_((
|
||||
BroadcastStatusType.PENDING_APPROVAL,
|
||||
BroadcastStatusType.BROADCASTING,
|
||||
)),
|
||||
BroadcastMessage.reference.in_(references_to_original_broadcast),
|
||||
BroadcastMessage.service_id == service_id
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_broadcast_event_by_id(broadcast_event_id):
|
||||
return BroadcastEvent.query.filter(BroadcastEvent.id == broadcast_event_id).one()
|
||||
|
||||
|
||||
def dao_get_broadcast_messages_for_service(service_id):
|
||||
return BroadcastMessage.query.filter(
|
||||
BroadcastMessage.service_id == service_id
|
||||
).order_by(BroadcastMessage.created_at)
|
||||
|
||||
|
||||
def dao_get_all_broadcast_messages():
|
||||
return db.session.query(
|
||||
BroadcastMessage.id,
|
||||
BroadcastMessage.reference,
|
||||
ServiceBroadcastSettings.channel,
|
||||
BroadcastMessage.content,
|
||||
BroadcastMessage.areas,
|
||||
BroadcastMessage.status,
|
||||
BroadcastMessage.starts_at,
|
||||
BroadcastMessage.finishes_at,
|
||||
BroadcastMessage.approved_at,
|
||||
BroadcastMessage.cancelled_at,
|
||||
).join(
|
||||
ServiceBroadcastSettings, ServiceBroadcastSettings.service_id == BroadcastMessage.service_id
|
||||
).filter(
|
||||
BroadcastMessage.starts_at >= datetime(2021, 5, 25, 0, 0, 0),
|
||||
BroadcastMessage.stubbed == False, # noqa
|
||||
BroadcastMessage.status.in_(BroadcastStatusType.LIVE_STATUSES)
|
||||
).order_by(desc(BroadcastMessage.starts_at)).all()
|
||||
|
||||
|
||||
def get_earlier_events_for_broadcast_event(broadcast_event_id):
|
||||
"""
|
||||
This is used to build up the references list.
|
||||
"""
|
||||
this_event = BroadcastEvent.query.get(broadcast_event_id)
|
||||
|
||||
return BroadcastEvent.query.filter(
|
||||
BroadcastEvent.broadcast_message_id == this_event.broadcast_message_id,
|
||||
BroadcastEvent.sent_at < this_event.sent_at
|
||||
).order_by(
|
||||
BroadcastEvent.sent_at.asc()
|
||||
).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
def create_broadcast_provider_message(broadcast_event, provider):
|
||||
broadcast_provider_message_id = uuid.uuid4()
|
||||
provider_message = BroadcastProviderMessage(
|
||||
id=broadcast_provider_message_id,
|
||||
broadcast_event=broadcast_event,
|
||||
provider=provider,
|
||||
status=BroadcastProviderMessageStatus.SENDING,
|
||||
)
|
||||
db.session.add(provider_message)
|
||||
db.session.commit()
|
||||
provider_message_number = None
|
||||
if provider == BroadcastProvider.VODAFONE:
|
||||
provider_message_number = BroadcastProviderMessageNumber(
|
||||
broadcast_provider_message_id=broadcast_provider_message_id)
|
||||
db.session.add(provider_message_number)
|
||||
db.session.commit()
|
||||
return provider_message
|
||||
|
||||
|
||||
@autocommit
|
||||
def update_broadcast_provider_message_status(broadcast_provider_message, *, status):
|
||||
broadcast_provider_message.status = status
|
||||
100
app/dao/broadcast_service_dao.py
Normal file
100
app/dao/broadcast_service_dao.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit, version_class
|
||||
from app.models import (
|
||||
BROADCAST_TYPE,
|
||||
EMAIL_AUTH_TYPE,
|
||||
INVITE_PENDING,
|
||||
VIEW_ACTIVITY,
|
||||
ApiKey,
|
||||
InvitedUser,
|
||||
Organisation,
|
||||
Permission,
|
||||
Service,
|
||||
ServiceBroadcastSettings,
|
||||
ServicePermission,
|
||||
)
|
||||
|
||||
|
||||
@autocommit
|
||||
@version_class(Service)
|
||||
def set_broadcast_service_type(service, service_mode, broadcast_channel, provider_restriction):
|
||||
insert_or_update_service_broadcast_settings(
|
||||
service, channel=broadcast_channel, provider_restriction=provider_restriction
|
||||
)
|
||||
|
||||
# Remove all permissions and add broadcast permission
|
||||
if not service.has_permission(BROADCAST_TYPE):
|
||||
service_permission = ServicePermission(service_id=service.id, permission=BROADCAST_TYPE)
|
||||
db.session.add(service_permission)
|
||||
|
||||
ServicePermission.query.filter(
|
||||
ServicePermission.service_id == service.id,
|
||||
ServicePermission.permission != BROADCAST_TYPE,
|
||||
# Email auth is an exception to the other service permissions (which relate to what type
|
||||
# of notifications a service can send) where a broadcast service is allowed to have the
|
||||
# email auth permission (but doesn't have to)
|
||||
ServicePermission.permission != EMAIL_AUTH_TYPE
|
||||
).delete()
|
||||
|
||||
# Refresh the service object as it has references to the service permissions but we don't yet
|
||||
# want to commit the permission changes incase all of this needs to rollback
|
||||
db.session.refresh(service)
|
||||
|
||||
# Set service count as live false always
|
||||
service.count_as_live = False
|
||||
|
||||
# Set service into training mode or live mode
|
||||
if service_mode == "live":
|
||||
if service.restricted:
|
||||
# Only update the go live at timestamp if this if moving from training mode
|
||||
# to live mode, not if it's moving from one type of live mode service to another
|
||||
service.go_live_at = datetime.utcnow()
|
||||
service.restricted = False
|
||||
else:
|
||||
service.restricted = True
|
||||
service.go_live_at = None
|
||||
|
||||
# Remove all user permissions apart from view_activity for the service users and invited users
|
||||
Permission.query.filter(
|
||||
Permission.service_id == service.id,
|
||||
Permission.permission != VIEW_ACTIVITY
|
||||
).delete()
|
||||
InvitedUser.query.filter_by(
|
||||
service_id=service.id,
|
||||
status=INVITE_PENDING
|
||||
).update({'permissions': VIEW_ACTIVITY})
|
||||
|
||||
# Revoke any API keys to avoid a regular API key being used to send alerts
|
||||
ApiKey.query.filter_by(
|
||||
service_id=service.id,
|
||||
expiry_date=None,
|
||||
).update({
|
||||
ApiKey.expiry_date: datetime.utcnow()
|
||||
})
|
||||
|
||||
# Add service to organisation
|
||||
organisation = Organisation.query.filter_by(
|
||||
id=current_app.config['BROADCAST_ORGANISATION_ID']
|
||||
).one()
|
||||
service.organisation_id = organisation.id
|
||||
service.organisation_type = organisation.organisation_type
|
||||
service.crown = organisation.crown
|
||||
|
||||
db.session.add(service)
|
||||
|
||||
|
||||
def insert_or_update_service_broadcast_settings(service, channel, provider_restriction="all"):
|
||||
if not service.service_broadcast_settings:
|
||||
settings = ServiceBroadcastSettings()
|
||||
settings.service = service
|
||||
settings.channel = channel
|
||||
settings.provider = provider_restriction
|
||||
db.session.add(settings)
|
||||
else:
|
||||
service.service_broadcast_settings.channel = channel
|
||||
service.service_broadcast_settings.provider = provider_restriction
|
||||
db.session.add(service.service_broadcast_settings)
|
||||
@@ -1,13 +1,12 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.dao.inbound_sms_dao import Pagination
|
||||
from app.models import Complaint
|
||||
from app.utils import get_midnight_in_utc
|
||||
from app.utils import get_london_midnight_in_utc
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -16,36 +15,20 @@ def save_complaint(complaint):
|
||||
|
||||
|
||||
def fetch_paginated_complaints(page=1):
|
||||
page_size = current_app.config["PAGE_SIZE"]
|
||||
total_count = db.session.scalar(select(func.count()).select_from(Complaint))
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
select(Complaint)
|
||||
.order_by(desc(Complaint.created_at))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
return Complaint.query.order_by(
|
||||
desc(Complaint.created_at)
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=current_app.config['PAGE_SIZE']
|
||||
)
|
||||
result = db.session.execute(stmt).scalars().all()
|
||||
pagination = Pagination(result, page=page, per_page=page_size, total=total_count)
|
||||
return pagination
|
||||
|
||||
|
||||
def fetch_complaints_by_service(service_id):
|
||||
stmt = (
|
||||
select(Complaint)
|
||||
.where(Complaint.service_id == service_id)
|
||||
.order_by(desc(Complaint.created_at))
|
||||
)
|
||||
return db.session.execute(stmt).scalars().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_london_midnight_in_utc(start_date)
|
||||
end_date = get_london_midnight_in_utc(end_date + timedelta(days=1))
|
||||
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Complaint)
|
||||
.where(Complaint.created_at >= start_date, Complaint.created_at < end_date)
|
||||
)
|
||||
return db.session.execute(stmt).scalar() or 0
|
||||
return Complaint.query.filter(Complaint.created_at >= start_date, Complaint.created_at < end_date).count()
|
||||
|
||||
38
app/dao/daily_sorted_letter_dao.py
Normal file
38
app/dao/daily_sorted_letter_dao.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import DailySortedLetter
|
||||
|
||||
|
||||
def dao_get_daily_sorted_letter_by_billing_day(billing_day):
|
||||
return DailySortedLetter.query.filter_by(
|
||||
billing_day=billing_day
|
||||
).first()
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_create_or_update_daily_sorted_letter(new_daily_sorted_letter):
|
||||
'''
|
||||
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 = DailySortedLetter.__table__
|
||||
stmt = insert(table).values(
|
||||
billing_day=new_daily_sorted_letter.billing_day,
|
||||
file_name=new_daily_sorted_letter.file_name,
|
||||
unsorted_count=new_daily_sorted_letter.unsorted_count,
|
||||
sorted_count=new_daily_sorted_letter.sorted_count)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[table.c.billing_day, table.c.file_name],
|
||||
set_={
|
||||
'unsorted_count': stmt.excluded.unsorted_count,
|
||||
'sorted_count': stmt.excluded.sorted_count,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
db.session.connection().execute(stmt)
|
||||
@@ -12,14 +12,13 @@ def autocommit(func):
|
||||
try:
|
||||
res = func(*args, **kwargs)
|
||||
|
||||
if not db.session().in_nested_transaction():
|
||||
if not db.session.registry().transaction.nested:
|
||||
db.session.commit()
|
||||
|
||||
return res
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
raise
|
||||
|
||||
return commit_or_rollback
|
||||
|
||||
|
||||
@@ -30,14 +29,15 @@ def transaction():
|
||||
yield
|
||||
db.session.commit()
|
||||
|
||||
if not db.session().in_nested_transaction():
|
||||
if not db.session.registry().transaction.nested:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
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,12 +1,16 @@
|
||||
import calendar
|
||||
from datetime import date, datetime, time, timedelta
|
||||
|
||||
from app.utils import utc_now
|
||||
import pytz
|
||||
from notifications_utils.timezones import convert_bst_to_utc
|
||||
|
||||
|
||||
def get_months_for_financial_year(year):
|
||||
return [
|
||||
month for month in (get_months_for_year(1, 13, year)) if month < datetime.now()
|
||||
convert_bst_to_utc(month) for month in (
|
||||
get_months_for_year(4, 13, year)
|
||||
+ get_months_for_year(1, 4, year + 1)
|
||||
)
|
||||
if convert_bst_to_utc(month) < datetime.now()
|
||||
]
|
||||
|
||||
|
||||
@@ -14,104 +18,57 @@ 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)
|
||||
|
||||
return (year_start_datetime.date(), year_end_datetime.date())
|
||||
def get_current_financial_year():
|
||||
now = datetime.utcnow()
|
||||
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_current_calendar_year():
|
||||
now = utc_now()
|
||||
current_year = int(now.strftime("%Y"))
|
||||
year = current_year
|
||||
return get_calendar_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 pytz.timezone('Europe/London').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_bst_to_utc(first_day), convert_bst_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
|
||||
|
||||
|
||||
def get_number_of_days_for_month(year, month):
|
||||
return calendar.monthrange(year, month)[1]
|
||||
|
||||
|
||||
def generate_date_range(start_date, end_date=None, days=0):
|
||||
if end_date:
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
try:
|
||||
yield current_date.date()
|
||||
except ValueError:
|
||||
pass
|
||||
current_date += timedelta(days=1)
|
||||
elif days > 0:
|
||||
end_date = start_date + timedelta(days=days)
|
||||
current_date = start_date
|
||||
while current_date < end_date:
|
||||
try:
|
||||
yield current_date.date()
|
||||
except ValueError:
|
||||
pass
|
||||
current_date += timedelta(days=1)
|
||||
else:
|
||||
return "An end_date or number of days must be specified"
|
||||
|
||||
|
||||
def generate_hourly_range(start_date, end_date=None, hours=0):
|
||||
if end_date:
|
||||
current_time = start_date
|
||||
while current_time <= end_date:
|
||||
try:
|
||||
yield current_time
|
||||
except ValueError:
|
||||
pass
|
||||
current_time += timedelta(hours=1)
|
||||
elif hours > 0:
|
||||
end_time = start_date + timedelta(hours=hours)
|
||||
current_time = start_date
|
||||
while current_time < end_time:
|
||||
try:
|
||||
yield current_time
|
||||
except ValueError:
|
||||
pass
|
||||
current_time += timedelta(hours=1)
|
||||
else:
|
||||
return "An end_date or number of hours must be specified"
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import EmailBranding
|
||||
|
||||
|
||||
def dao_get_email_branding_options():
|
||||
return db.session.execute(select(EmailBranding)).scalars().all()
|
||||
return EmailBranding.query.all()
|
||||
|
||||
|
||||
def dao_get_email_branding_by_id(email_branding_id):
|
||||
return (
|
||||
db.session.execute(
|
||||
select(EmailBranding).where(EmailBranding.id == email_branding_id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
return EmailBranding.query.filter_by(id=email_branding_id).one()
|
||||
|
||||
|
||||
def dao_get_email_branding_by_name(email_branding_name):
|
||||
return (
|
||||
db.session.execute(
|
||||
select(EmailBranding).where(EmailBranding.name == email_branding_name)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
return EmailBranding.query.filter_by(name=email_branding_name).first()
|
||||
|
||||
|
||||
@autocommit
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,53 @@
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.sql.expression import case
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import FactProcessingTime
|
||||
from app.utils import utc_now
|
||||
|
||||
|
||||
@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,
|
||||
bst_date=processing_time.bst_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],
|
||||
index_elements=[table.c.bst_date],
|
||||
set_={
|
||||
"messages_total": stmt.excluded.messages_total,
|
||||
"messages_within_10_secs": stmt.excluded.messages_within_10_secs,
|
||||
"updated_at": utc_now(),
|
||||
},
|
||||
'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 = (
|
||||
select(
|
||||
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"),
|
||||
)
|
||||
.where(
|
||||
FactProcessingTime.local_date >= start_date,
|
||||
FactProcessingTime.local_date <= end_date,
|
||||
)
|
||||
.order_by(FactProcessingTime.local_date)
|
||||
)
|
||||
query = db.session.query(
|
||||
FactProcessingTime.bst_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.bst_date >= start_date,
|
||||
FactProcessingTime.bst_date <= end_date
|
||||
).order_by(FactProcessingTime.bst_date)
|
||||
|
||||
return db.session.execute(query).all()
|
||||
return query.all()
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
from sqlalchemy import and_, select, update
|
||||
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import InboundNumber
|
||||
|
||||
|
||||
def dao_get_inbound_numbers():
|
||||
stmt = select(InboundNumber).order_by(InboundNumber.updated_at)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
return InboundNumber.query.order_by(InboundNumber.updated_at).all()
|
||||
|
||||
|
||||
def dao_get_available_inbound_numbers():
|
||||
stmt = select(InboundNumber).where(
|
||||
InboundNumber.active, InboundNumber.service_id.is_(None)
|
||||
)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
return InboundNumber.query.filter(InboundNumber.active, InboundNumber.service_id.is_(None)).all()
|
||||
|
||||
|
||||
def dao_get_inbound_number_for_service(service_id):
|
||||
stmt = select(InboundNumber).where(InboundNumber.service_id == service_id)
|
||||
return db.session.execute(stmt).scalars().first()
|
||||
return InboundNumber.query.filter(InboundNumber.service_id == service_id).first()
|
||||
|
||||
|
||||
def dao_get_inbound_number(inbound_number_id):
|
||||
stmt = select(InboundNumber).where(InboundNumber.id == inbound_number_id)
|
||||
return db.session.execute(stmt).scalars().first()
|
||||
return InboundNumber.query.filter(InboundNumber.id == inbound_number_id).first()
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -35,8 +27,7 @@ def dao_set_inbound_number_to_service(service_id, inbound_number):
|
||||
|
||||
@autocommit
|
||||
def dao_set_inbound_number_active_flag(service_id, active):
|
||||
stmt = select(InboundNumber).where(InboundNumber.service_id == service_id)
|
||||
inbound_number = db.session.execute(stmt).scalars().first()
|
||||
inbound_number = InboundNumber.query.filter(InboundNumber.service_id == service_id).first()
|
||||
inbound_number.active = active
|
||||
|
||||
db.session.add(inbound_number)
|
||||
@@ -44,18 +35,13 @@ def dao_set_inbound_number_active_flag(service_id, active):
|
||||
|
||||
@autocommit
|
||||
def dao_allocate_number_for_service(service_id, inbound_number_id):
|
||||
stmt = (
|
||||
update(InboundNumber)
|
||||
.where(
|
||||
and_(
|
||||
InboundNumber.id == inbound_number_id, # noqa
|
||||
InboundNumber.active == True, # noqa
|
||||
InboundNumber.service_id == None, # noqa
|
||||
)
|
||||
)
|
||||
.values({"service_id": service_id})
|
||||
updated = InboundNumber.query.filter_by(
|
||||
id=inbound_number_id,
|
||||
active=True,
|
||||
service_id=None
|
||||
).update(
|
||||
{"service_id": service_id}
|
||||
)
|
||||
result = db.session.execute(stmt)
|
||||
if result.rowcount == 0:
|
||||
if not updated:
|
||||
raise Exception("Inbound number: {} is not available".format(inbound_number_id))
|
||||
return db.session.get(InboundNumber, inbound_number_id)
|
||||
return InboundNumber.query.get(inbound_number_id)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from flask import current_app
|
||||
from sqlalchemy import and_, delete, desc, func, select
|
||||
from sqlalchemy import and_, desc
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
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, ServiceDataRetention
|
||||
from app.models import (
|
||||
SMS_TYPE,
|
||||
InboundSms,
|
||||
InboundSmsHistory,
|
||||
Service,
|
||||
ServiceDataRetention,
|
||||
)
|
||||
from app.utils import midnight_n_days_ago
|
||||
|
||||
|
||||
@@ -15,99 +20,77 @@ 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 = (
|
||||
select(InboundSms)
|
||||
.where(InboundSms.service_id == service_id)
|
||||
.order_by(InboundSms.created_at.desc())
|
||||
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:
|
||||
start_date = midnight_n_days_ago(limit_days)
|
||||
q = q.where(InboundSms.created_at >= start_date)
|
||||
q = q.filter(InboundSms.created_at >= start_date)
|
||||
|
||||
if user_number:
|
||||
q = q.where(InboundSms.user_number == user_number)
|
||||
q = q.filter(InboundSms.user_number == user_number)
|
||||
|
||||
if limit:
|
||||
q = q.limit(limit)
|
||||
|
||||
return db.session.execute(q).scalars().all()
|
||||
return q.all()
|
||||
|
||||
|
||||
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)
|
||||
.where(InboundSms.id == older_than)
|
||||
.scalar_subquery()
|
||||
)
|
||||
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)
|
||||
|
||||
page = 1 # ?
|
||||
offset = (page - 1) * page_size
|
||||
# As part of the move to sqlalchemy 2.0, we do this manual pagination
|
||||
stmt = (
|
||||
select(InboundSms)
|
||||
.where(*filters)
|
||||
.order_by(desc(InboundSms.created_at))
|
||||
.limit(page_size)
|
||||
.offset(offset)
|
||||
)
|
||||
paginated_items = db.session.execute(stmt).scalars().all()
|
||||
total_items = db.session.execute(select(func.count()).where(*filters)).scalar() or 0
|
||||
pagination = Pagination(paginated_items, page, page_size, total_items)
|
||||
return pagination
|
||||
query = InboundSms.query.filter(*filters)
|
||||
|
||||
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):
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(InboundSms)
|
||||
.where(
|
||||
InboundSms.service_id == service_id,
|
||||
InboundSms.created_at >= midnight_n_days_ago(limit_days),
|
||||
)
|
||||
)
|
||||
result = db.session.execute(stmt).scalar()
|
||||
return result
|
||||
return InboundSms.query.filter(
|
||||
InboundSms.service_id == service_id,
|
||||
InboundSms.created_at >= midnight_n_days_ago(limit_days)
|
||||
).count()
|
||||
|
||||
|
||||
def _insert_inbound_sms_history(subquery, query_limit=10000):
|
||||
offset = 0
|
||||
subquery_select = select(subquery)
|
||||
inbound_sms_stmt = select(
|
||||
inbound_sms_query = db.session.query(
|
||||
InboundSms.id,
|
||||
InboundSms.created_at,
|
||||
InboundSms.service_id,
|
||||
InboundSms.notify_number,
|
||||
InboundSms.provider_date,
|
||||
InboundSms.provider_reference,
|
||||
InboundSms.provider,
|
||||
).where(InboundSms.id.in_(subquery_select))
|
||||
|
||||
count_query = select(func.count()).select_from(inbound_sms_stmt.subquery())
|
||||
inbound_sms_count = db.session.execute(count_query).scalar() or 0
|
||||
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_stmt.limit(query_limit).offset(offset),
|
||||
inbound_sms_query.limit(query_limit).offset(offset)
|
||||
)
|
||||
|
||||
statement = statement.on_conflict_do_nothing(
|
||||
constraint="inbound_sms_history_pkey"
|
||||
)
|
||||
db.session.execute(statement)
|
||||
db.session.commit()
|
||||
db.session.connection().execute(statement)
|
||||
|
||||
offset += query_limit
|
||||
|
||||
@@ -115,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 = (
|
||||
select(InboundSms.id)
|
||||
.where(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
|
||||
@@ -128,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)
|
||||
|
||||
stmt = delete(InboundSms).where(InboundSms.id.in_(subquery))
|
||||
number_deleted = db.session.execute(stmt).rowcount
|
||||
db.session.commit()
|
||||
number_deleted = InboundSms.query.filter(InboundSms.id.in_(subquery)).delete(synchronize_session='fetch')
|
||||
deleted += number_deleted
|
||||
|
||||
return deleted
|
||||
@@ -138,56 +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')
|
||||
|
||||
stmt = (
|
||||
select(ServiceDataRetention)
|
||||
.join(ServiceDataRetention.service)
|
||||
.where(ServiceDataRetention.notification_type == NotificationType.SMS)
|
||||
)
|
||||
flexible_data_retention = db.session.execute(stmt).scalars().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):
|
||||
stmt = select(InboundSms).where(
|
||||
InboundSms.id == inbound_id, InboundSms.service_id == service_id
|
||||
)
|
||||
return db.session.execute(stmt).scalars().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.
|
||||
@@ -208,44 +182,24 @@ def dao_get_paginated_most_recent_inbound_sms_by_user_number_for_service(
|
||||
LIMIT 50 OFFSET :page
|
||||
"""
|
||||
t2 = aliased(InboundSms)
|
||||
q = (
|
||||
select(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,
|
||||
)
|
||||
.where(
|
||||
t2.id.is_(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()
|
||||
)
|
||||
result = db.session.execute(q).scalars().all()
|
||||
page_size = current_app.config["PAGE_SIZE"]
|
||||
offset = (page - 1) * page_size
|
||||
paginated_results = result[offset : offset + page_size]
|
||||
pagination = Pagination(paginated_results, page, page_size, len(result))
|
||||
return pagination
|
||||
|
||||
|
||||
# TODO remove this when billing dao PR is merged.
|
||||
class Pagination:
|
||||
def __init__(self, items, page, per_page, total):
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total = total
|
||||
self.pages = (total + per_page - 1) // per_page
|
||||
self.prev_num = page - 1 if page > 1 else None
|
||||
self.next_num = page + 1 if page < self.pages else None
|
||||
|
||||
def has_next(self):
|
||||
return self.page < self.pages
|
||||
|
||||
def has_prev(self):
|
||||
return self.page > 1
|
||||
return q.paginate(
|
||||
page=page,
|
||||
per_page=current_app.config['PAGE_SIZE']
|
||||
)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import db
|
||||
from app.models import InvitedOrganizationUser
|
||||
from app.utils import utc_now
|
||||
from app.models import InvitedOrganisationUser
|
||||
|
||||
|
||||
def save_invited_org_user(invited_org_user):
|
||||
@@ -12,48 +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 (
|
||||
db.session.execute(
|
||||
select(InvitedOrganizationUser).where(
|
||||
InvitedOrganizationUser.organization_id == organization_id,
|
||||
InvitedOrganizationUser.id == invited_org_user_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.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 (
|
||||
db.session.execute(
|
||||
select(InvitedOrganizationUser).where(
|
||||
InvitedOrganizationUser.id == invited_org_user_id
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
return InvitedOrganisationUser.query.filter_by(id=invited_org_user_id).one()
|
||||
|
||||
|
||||
def get_invited_org_users_for_organization(organization_id):
|
||||
return (
|
||||
db.session.execute(
|
||||
select(InvitedOrganizationUser).where(
|
||||
InvitedOrganizationUser.organization_id == organization_id
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.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)
|
||||
.where(InvitedOrganizationUser.created_at <= utc_now() - 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,11 +1,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import db
|
||||
from app.enums import InvitedUserStatus
|
||||
from app.models import InvitedUser
|
||||
from app.utils import utc_now
|
||||
|
||||
|
||||
def save_invited_user(invited_user):
|
||||
@@ -14,47 +10,20 @@ def save_invited_user(invited_user):
|
||||
|
||||
|
||||
def get_invited_user_by_service_and_id(service_id, invited_user_id):
|
||||
|
||||
stmt = select(InvitedUser).where(
|
||||
InvitedUser.service_id == service_id,
|
||||
InvitedUser.id == invited_user_id,
|
||||
)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
|
||||
|
||||
def get_expired_invite_by_service_and_id(service_id, invited_user_id):
|
||||
stmt = select(InvitedUser).where(
|
||||
InvitedUser.service_id == service_id,
|
||||
InvitedUser.id == invited_user_id,
|
||||
InvitedUser.status == InvitedUserStatus.EXPIRED,
|
||||
)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
return InvitedUser.query.filter_by(service_id=service_id, id=invited_user_id).one()
|
||||
|
||||
|
||||
def get_invited_user_by_id(invited_user_id):
|
||||
stmt = select(InvitedUser).where(InvitedUser.id == invited_user_id)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
|
||||
|
||||
def get_expired_invited_users_for_service(service_id):
|
||||
# TODO why does this return all invited users?
|
||||
stmt = select(InvitedUser).where(InvitedUser.service_id == service_id)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
return InvitedUser.query.filter_by(id=invited_user_id).one()
|
||||
|
||||
|
||||
def get_invited_users_for_service(service_id):
|
||||
stmt = select(InvitedUser).where(InvitedUser.service_id == service_id)
|
||||
return db.session.execute(stmt).scalars().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)
|
||||
.where(
|
||||
InvitedUser.created_at <= utc_now() - 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
|
||||
|
||||
@@ -1,52 +1,56 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import and_, asc, desc, func, select, update
|
||||
from notifications_utils.letter_timings import (
|
||||
CANCELLABLE_JOB_LETTER_STATUSES,
|
||||
letter_can_be_cancelled,
|
||||
)
|
||||
from sqlalchemy import and_, asc, desc, func
|
||||
|
||||
from app import db
|
||||
from app.dao.pagination import Pagination
|
||||
from app.enums import JobStatus
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.dao.templates_dao import dao_get_template_by_id
|
||||
from app.models import (
|
||||
JOB_STATUS_CANCELLED,
|
||||
JOB_STATUS_FINISHED,
|
||||
JOB_STATUS_PENDING,
|
||||
JOB_STATUS_SCHEDULED,
|
||||
LETTER_TYPE,
|
||||
NOTIFICATION_CANCELLED,
|
||||
NOTIFICATION_CREATED,
|
||||
FactNotificationStatus,
|
||||
Job,
|
||||
Notification,
|
||||
ServiceDataRetention,
|
||||
Template,
|
||||
)
|
||||
from app.utils import midnight_n_days_ago, utc_now
|
||||
from app.utils import midnight_n_days_ago
|
||||
|
||||
|
||||
def dao_get_notification_outcomes_for_job(service_id, job_id):
|
||||
stmt = (
|
||||
select(func.count(Notification.status).label("count"), Notification.status)
|
||||
.where(Notification.service_id == service_id, Notification.job_id == job_id)
|
||||
.group_by(Notification.status)
|
||||
)
|
||||
notification_statuses = db.session.execute(stmt).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:
|
||||
stmt = select(
|
||||
FactNotificationStatus.notification_count.label("count"),
|
||||
FactNotificationStatus.notification_status.label("status"),
|
||||
).where(
|
||||
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,
|
||||
)
|
||||
notification_statuses = db.session.execute(stmt).all()
|
||||
FactNotificationStatus.job_id == job_id
|
||||
).all()
|
||||
return notification_statuses
|
||||
|
||||
|
||||
def dao_get_job_by_service_id_and_job_id(service_id, job_id):
|
||||
stmt = select(Job).where(Job.service_id == service_id, Job.id == job_id)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
|
||||
|
||||
def dao_get_unfinished_jobs():
|
||||
|
||||
stmt = select(Job).filter(Job.processing_finished.is_(None))
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
return Job.query.filter_by(service_id=service_id, id=job_id).one()
|
||||
|
||||
|
||||
def dao_get_jobs_by_service_id(
|
||||
@@ -56,50 +60,41 @@ 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))
|
||||
|
||||
total_items = db.session.execute(
|
||||
select(func.count()).select_from(Job).where(*query_filter)
|
||||
).scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
select(Job)
|
||||
.where(*query_filter)
|
||||
.order_by(Job.processing_started.desc(), Job.created_at.desc())
|
||||
.limit(page_size)
|
||||
.offset(offset)
|
||||
)
|
||||
items = db.session.execute(stmt).scalars().all()
|
||||
return Pagination(items, page, page_size, total_items)
|
||||
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,
|
||||
):
|
||||
|
||||
stmt = select(
|
||||
return db.session.query(
|
||||
func.count(Job.id),
|
||||
func.min(Job.scheduled_for),
|
||||
).where(
|
||||
).filter(
|
||||
Job.service_id == service_id,
|
||||
Job.job_status == JobStatus.SCHEDULED,
|
||||
)
|
||||
return db.session.execute(stmt).one()
|
||||
Job.job_status == JOB_STATUS_SCHEDULED,
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_job_by_id(job_id):
|
||||
stmt = select(Job).where(Job.id == job_id)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
return Job.query.filter_by(id=job_id).one()
|
||||
|
||||
|
||||
def dao_archive_job(job):
|
||||
@@ -116,19 +111,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.
|
||||
"""
|
||||
stmt = (
|
||||
select(Job)
|
||||
.where(
|
||||
Job.job_status == JobStatus.SCHEDULED,
|
||||
Job.scheduled_for < utc_now(),
|
||||
)
|
||||
.order_by(asc(Job.scheduled_for))
|
||||
.with_for_update()
|
||||
)
|
||||
jobs = db.session.execute(stmt).scalars().all()
|
||||
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()
|
||||
@@ -137,13 +130,14 @@ def dao_set_scheduled_jobs_to_pending():
|
||||
|
||||
|
||||
def dao_get_future_scheduled_job_by_id_and_service_id(job_id, service_id):
|
||||
stmt = select(Job).where(
|
||||
Job.service_id == service_id,
|
||||
Job.id == job_id,
|
||||
Job.job_status == JobStatus.SCHEDULED,
|
||||
Job.scheduled_for > utc_now(),
|
||||
)
|
||||
return db.session.execute(stmt).scalars().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):
|
||||
@@ -151,25 +145,6 @@ def dao_create_job(job):
|
||||
job.id = uuid.uuid4()
|
||||
db.session.add(job)
|
||||
db.session.commit()
|
||||
# We are seeing weird time anomalies where a job can be created on
|
||||
# 8/19 yet show a created_at time of 8/16. This seems to be the only
|
||||
# place the created_at value is set so do some double-checking and debugging
|
||||
orig_time = job.created_at
|
||||
now_time = utc_now()
|
||||
diff_time = now_time - orig_time
|
||||
current_app.logger.info(
|
||||
f"#notify-debug-admin-1859 dao_create_job orig created at {orig_time} and now {now_time}"
|
||||
)
|
||||
if diff_time.total_seconds() > 300: # It should be only a few seconds diff at most
|
||||
current_app.logger.error(
|
||||
"#notify-debug-admin-1859 Something is wrong with job.created_at!"
|
||||
)
|
||||
if os.getenv("NOTIFY_ENVIRONMENT") not in ["test"]:
|
||||
job.created_at = now_time
|
||||
dao_update_job(job)
|
||||
current_app.logger.error(
|
||||
f"#notify-debug-admin-1859 Job created_at reset to {job.created_at}"
|
||||
)
|
||||
|
||||
|
||||
def dao_update_job(job):
|
||||
@@ -177,94 +152,104 @@ def dao_update_job(job):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def dao_update_job_status_to_error(job):
|
||||
stmt = update(Job).where(Job.id == job.id).values(job_status=JobStatus.ERROR)
|
||||
db.session.execute(stmt)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def dao_get_jobs_older_than_data_retention(notification_types):
|
||||
stmt = select(ServiceDataRetention).where(
|
||||
flexible_data_retention = ServiceDataRetention.query.filter(
|
||||
ServiceDataRetention.notification_type.in_(notification_types)
|
||||
)
|
||||
flexible_data_retention = db.session.execute(stmt).scalars().all()
|
||||
).all()
|
||||
jobs = []
|
||||
today = utc_now().date()
|
||||
today = datetime.utcnow().date()
|
||||
for f in flexible_data_retention:
|
||||
end_date = today - timedelta(days=f.days_of_retention)
|
||||
stmt = (
|
||||
select(Job)
|
||||
.join(Template)
|
||||
.where(
|
||||
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))
|
||||
)
|
||||
jobs.extend(db.session.execute(stmt).scalars().all())
|
||||
|
||||
# notify-api-1287, make default data retention 7 days, 23 hours
|
||||
end_date = today - timedelta(days=7, hours=23)
|
||||
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
|
||||
]
|
||||
stmt = (
|
||||
select(Job)
|
||||
.join(Template)
|
||||
.where(
|
||||
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))
|
||||
)
|
||||
jobs.extend(db.session.execute(stmt).scalars().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
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_cancel_letter_job(job):
|
||||
number_of_notifications_cancelled = Notification.query.filter(
|
||||
Notification.job_id == job.id
|
||||
).update({'status': NOTIFICATION_CANCELLED,
|
||||
'updated_at': datetime.utcnow(),
|
||||
'billable_units': 0})
|
||||
job.job_status = JOB_STATUS_CANCELLED
|
||||
dao_update_job(job)
|
||||
return number_of_notifications_cancelled
|
||||
|
||||
|
||||
def can_letter_job_be_cancelled(job):
|
||||
template = dao_get_template_by_id(job.template_id)
|
||||
if template.template_type != LETTER_TYPE:
|
||||
return False, "Only letter jobs can be cancelled through this endpoint. This is not a letter job."
|
||||
|
||||
notifications = Notification.query.filter(
|
||||
Notification.job_id == job.id
|
||||
).all()
|
||||
count_notifications = len(notifications)
|
||||
if job.job_status != JOB_STATUS_FINISHED or count_notifications != job.notification_count:
|
||||
return False, "We are still processing these letters, please try again in a minute."
|
||||
count_cancellable_notifications = len([
|
||||
n for n in notifications if n.status in CANCELLABLE_JOB_LETTER_STATUSES
|
||||
])
|
||||
if count_cancellable_notifications != job.notification_count or not letter_can_be_cancelled(
|
||||
NOTIFICATION_CREATED, job.created_at
|
||||
):
|
||||
return False, "It’s too late to cancel sending, these letters have already been sent."
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def find_jobs_with_missing_rows():
|
||||
# Jobs can be a maximum of 100,000 rows. It typically takes 10 minutes to create all those notifications.
|
||||
# Using 20 minutes as a condition seems reasonable.
|
||||
ten_minutes_ago = utc_now() - timedelta(minutes=20)
|
||||
yesterday = utc_now() - timedelta(days=1)
|
||||
jobs_with_rows_missing = (
|
||||
select(Job)
|
||||
.where(
|
||||
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)
|
||||
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 == 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 db.session.execute(jobs_with_rows_missing).scalars().all()
|
||||
return jobs_with_rows_missing.all()
|
||||
|
||||
|
||||
def find_missing_row_for_job(job_id, job_size):
|
||||
expected_row_numbers = select(
|
||||
func.generate_series(0, job_size - 1).label("row")
|
||||
expected_row_numbers = db.session.query(
|
||||
func.generate_series(0, job_size - 1).label('row')
|
||||
).subquery()
|
||||
|
||||
query = (
|
||||
select(
|
||||
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,
|
||||
),
|
||||
)
|
||||
.where(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 db.session.execute(query).all()
|
||||
return query.all()
|
||||
|
||||
29
app/dao/letter_branding_dao.py
Normal file
29
app/dao/letter_branding_dao.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from app import db
|
||||
from app.dao.dao_utils import autocommit
|
||||
from app.models import LetterBranding
|
||||
|
||||
|
||||
def dao_get_letter_branding_by_id(letter_branding_id):
|
||||
return LetterBranding.query.filter(LetterBranding.id == letter_branding_id).one()
|
||||
|
||||
|
||||
def dao_get_letter_branding_by_name(letter_branding_name):
|
||||
return LetterBranding.query.filter_by(name=letter_branding_name).first()
|
||||
|
||||
|
||||
def dao_get_all_letter_branding():
|
||||
return LetterBranding.query.order_by(LetterBranding.name).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_create_letter_branding(letter_branding):
|
||||
db.session.add(letter_branding)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_update_letter_branding(letter_branding_id, **kwargs):
|
||||
letter_branding = LetterBranding.query.get(letter_branding_id)
|
||||
for key, value in kwargs.items():
|
||||
setattr(letter_branding, key, value or None)
|
||||
db.session.add(letter_branding)
|
||||
return letter_branding
|
||||
File diff suppressed because it is too large
Load Diff
144
app/dao/organisation_dao.py
Normal file
144
app/dao/organisation_dao.py
Normal file
@@ -0,0 +1,144 @@
|
||||
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')
|
||||
|
||||
if 'letter_branding_id' in kwargs:
|
||||
_update_organisation_services(organisation, 'letter_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,146 +0,0 @@
|
||||
from sqlalchemy import delete, select, update
|
||||
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():
|
||||
stmt = select(Organization).order_by(
|
||||
Organization.active.desc(), Organization.name.asc()
|
||||
)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def dao_count_organizations_with_live_services():
|
||||
stmt = (
|
||||
select(func.count(func.distinct(Organization.id)))
|
||||
.join(Organization.services)
|
||||
.where(
|
||||
Service.active.is_(True),
|
||||
Service.restricted.is_(False),
|
||||
Service.count_as_live.is_(True),
|
||||
)
|
||||
)
|
||||
return db.session.execute(stmt).scalar() or 0
|
||||
|
||||
|
||||
def dao_get_organization_services(organization_id):
|
||||
stmt = select(Organization).where(Organization.id == organization_id)
|
||||
return db.session.execute(stmt).scalars().one().services
|
||||
|
||||
|
||||
def dao_get_organization_live_services(organization_id):
|
||||
stmt = select(Service).where(
|
||||
Service.organization_id == organization_id, Service.restricted == False # noqa
|
||||
)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def dao_get_organization_by_id(organization_id):
|
||||
stmt = select(Organization).where(Organization.id == organization_id)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
|
||||
|
||||
def dao_get_organization_by_email_address(email_address):
|
||||
email_address = email_address.lower().replace(".gsi.gov.uk", ".gov.uk")
|
||||
stmt = select(Domain).order_by(func.char_length(Domain.domain).desc())
|
||||
domains = db.session.execute(stmt).scalars().all()
|
||||
for domain in domains:
|
||||
if email_address.endswith(
|
||||
"@{}".format(domain.domain)
|
||||
) or email_address.endswith(".{}".format(domain.domain)):
|
||||
stmt = select(Organization).where(Organization.id == domain.organization_id)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def dao_get_organization_by_service_id(service_id):
|
||||
stmt = (
|
||||
select(Organization).join(Organization.services).where(Service.id == service_id)
|
||||
)
|
||||
return db.session.execute(stmt).scalars().first()
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_create_organization(organization):
|
||||
db.session.add(organization)
|
||||
|
||||
|
||||
@autocommit
|
||||
def dao_update_organization(organization_id, **kwargs):
|
||||
domains = kwargs.pop("domains", None)
|
||||
stmt = (
|
||||
update(Organization).where(Organization.id == organization_id).values(**kwargs)
|
||||
)
|
||||
num_updated = db.session.execute(stmt).rowcount
|
||||
|
||||
if isinstance(domains, list):
|
||||
stmt = delete(Domain).where(Domain.organization_id == organization_id)
|
||||
db.session.execute(stmt)
|
||||
db.session.bulk_save_objects(
|
||||
[
|
||||
Domain(domain=domain.lower(), organization_id=organization_id)
|
||||
for domain in domains
|
||||
]
|
||||
)
|
||||
|
||||
organization = db.session.get(Organization, 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):
|
||||
stmt = select(Organization).where(Organization.id == organization_id)
|
||||
organization = db.session.execute(stmt).scalars().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)
|
||||
.where(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)
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
user = db.session.execute(stmt).scalars().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 +0,0 @@
|
||||
class Pagination:
|
||||
def __init__(self, items, page, per_page, total):
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total = total
|
||||
self.pages = (total + per_page - 1) // per_page
|
||||
self.prev_num = page - 1 if page > 1 else None
|
||||
self.next_num = page + 1 if page < self.pages else None
|
||||
|
||||
def has_next(self):
|
||||
return self.page < self.pages
|
||||
|
||||
def has_prev(self):
|
||||
return self.page > 1
|
||||
@@ -1,44 +1,52 @@
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app import db
|
||||
from app.dao import DAOClass
|
||||
from app.enums import PermissionType
|
||||
from app.models import Permission, Service
|
||||
from app.models import (
|
||||
MANAGE_API_KEYS,
|
||||
MANAGE_SETTINGS,
|
||||
MANAGE_TEMPLATES,
|
||||
MANAGE_USERS,
|
||||
SEND_EMAILS,
|
||||
SEND_LETTERS,
|
||||
SEND_TEXTS,
|
||||
VIEW_ACTIVITY,
|
||||
Permission,
|
||||
)
|
||||
|
||||
# Default permissions for a service
|
||||
default_service_permissions = [
|
||||
MANAGE_USERS,
|
||||
MANAGE_TEMPLATES,
|
||||
MANAGE_SETTINGS,
|
||||
SEND_TEXTS,
|
||||
SEND_EMAILS,
|
||||
SEND_LETTERS,
|
||||
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)
|
||||
|
||||
def remove_user_service_permissions(self, user, service):
|
||||
db.session.execute(
|
||||
delete(self.Meta.model).where(
|
||||
self.Meta.model.user == user, self.Meta.model.service == service
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
query = self.Meta.model.query.filter_by(user=user, service=service)
|
||||
query.delete()
|
||||
|
||||
def remove_user_service_permissions_for_all_services(self, user):
|
||||
db.session.execute(delete(self.Meta.model).where(self.Meta.model.user == user))
|
||||
db.session.commit()
|
||||
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:
|
||||
db.session.execute(
|
||||
delete(self.Meta.model).where(
|
||||
self.Meta.model.user == user, self.Meta.model.service == service
|
||||
)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
query = self.Meta.model.query.filter_by(user=user, service=service)
|
||||
query.delete()
|
||||
for p in permissions:
|
||||
p.user = user
|
||||
p.service = service
|
||||
@@ -52,29 +60,12 @@ class PermissionDAO(DAOClass):
|
||||
db.session.commit()
|
||||
|
||||
def get_permissions_by_user_id(self, user_id):
|
||||
return (
|
||||
db.session.execute(
|
||||
select(Permission)
|
||||
.join(Service)
|
||||
.where(Permission.user_id == user_id)
|
||||
.where(Service.active.is_(True))
|
||||
)
|
||||
.scalars()
|
||||
.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 (
|
||||
db.session.execute(
|
||||
select(Permission)
|
||||
.join(Service)
|
||||
.where(Permission.user_id == user_id)
|
||||
.where(Service.active.is_(True))
|
||||
.where(Service.id == service_id)
|
||||
)
|
||||
.scalars()
|
||||
.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,39 +1,58 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import desc, func, select
|
||||
from notifications_utils.timezones import convert_utc_to_bst
|
||||
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.utils import utc_now
|
||||
from app.models import (
|
||||
SMS_TYPE,
|
||||
FactBilling,
|
||||
ProviderDetails,
|
||||
ProviderDetailsHistory,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def get_provider_details_by_id(provider_details_id):
|
||||
return db.session.get(ProviderDetails, provider_details_id)
|
||||
return ProviderDetails.query.get(provider_details_id)
|
||||
|
||||
|
||||
def get_provider_details_by_identifier(identifier):
|
||||
stmt = select(ProviderDetails).where(ProviderDetails.identifier == identifier)
|
||||
return db.session.execute(stmt).scalars().one()
|
||||
return ProviderDetails.query.filter_by(identifier=identifier).one()
|
||||
|
||||
|
||||
def get_alternative_sms_provider(identifier):
|
||||
if identifier == "sns":
|
||||
raise Exception("No alternative SMS providers currently available")
|
||||
raise ValueError("Unrecognised sms provider {}".format(identifier))
|
||||
if identifier == 'firetext':
|
||||
return 'mmg'
|
||||
elif identifier == 'mmg':
|
||||
return 'firetext'
|
||||
raise ValueError('Unrecognised sms provider {}'.format(identifier))
|
||||
|
||||
|
||||
def dao_get_provider_versions(provider_id):
|
||||
stmt = (
|
||||
select(ProviderDetailsHistory)
|
||||
.where(ProviderDetailsHistory.id == provider_id)
|
||||
.order_by(desc(ProviderDetailsHistory.version))
|
||||
.limit(100)
|
||||
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}'
|
||||
)
|
||||
# limit results instead of adding pagination
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
provider.priority = new_priority
|
||||
|
||||
# Automatic update so set as notify user
|
||||
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
|
||||
_update_provider_details_without_commit(provider)
|
||||
|
||||
|
||||
def _get_sms_providers_for_update(time_threshold):
|
||||
@@ -45,39 +64,78 @@ def _get_sms_providers_for_update(time_threshold):
|
||||
release the transaction in that case
|
||||
"""
|
||||
# get current priority of both providers
|
||||
stmt = (
|
||||
select(ProviderDetails)
|
||||
.where(
|
||||
ProviderDetails.notification_type == NotificationType.SMS,
|
||||
ProviderDetails.active,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
q = db.session.execute(stmt).scalars().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) > utc_now() - 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)
|
||||
|
||||
stmt = select(ProviderDetails).where(*filters)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
return ProviderDetails.query.filter(*filters).order_by(asc(ProviderDetails.priority)).all()
|
||||
|
||||
|
||||
@autocommit
|
||||
@@ -90,7 +148,7 @@ def _update_provider_details_without_commit(provider_details):
|
||||
Doesn't commit, for when you need to control the database transaction manually
|
||||
"""
|
||||
provider_details.version += 1
|
||||
provider_details.updated_at = utc_now()
|
||||
provider_details.updated_at = datetime.utcnow()
|
||||
history = ProviderDetailsHistory.from_original(provider_details)
|
||||
db.session.add(provider_details)
|
||||
db.session.add(history)
|
||||
@@ -99,44 +157,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 = utc_now()
|
||||
first_day_of_the_month = current_datetime.date().replace(day=1)
|
||||
current_bst_datetime = convert_utc_to_bst(datetime.utcnow())
|
||||
first_day_of_the_month = current_bst_datetime.date().replace(day=1)
|
||||
|
||||
substmt = (
|
||||
db.session.query(
|
||||
FactBilling.provider,
|
||||
func.sum(FactBilling.billable_units * FactBilling.rate_multiplier).label(
|
||||
"current_month_billable_sms"
|
||||
),
|
||||
)
|
||||
.where(
|
||||
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.bst_date >= first_day_of_the_month
|
||||
).group_by(
|
||||
FactBilling.provider
|
||||
).subquery()
|
||||
|
||||
result = (
|
||||
db.session.query(
|
||||
ProviderDetails.id,
|
||||
ProviderDetails.display_name,
|
||||
ProviderDetails.identifier,
|
||||
ProviderDetails.notification_type,
|
||||
ProviderDetails.active,
|
||||
ProviderDetails.updated_at,
|
||||
ProviderDetails.supports_international,
|
||||
User.name.label("created_by_name"),
|
||||
func.coalesce(substmt.c.current_month_billable_sms, 0).label(
|
||||
"current_month_billable_sms"
|
||||
),
|
||||
)
|
||||
.outerjoin(substmt, ProviderDetails.identifier == substmt.c.provider)
|
||||
.outerjoin(User, ProviderDetails.created_by_id == User.id)
|
||||
.order_by(
|
||||
ProviderDetails.notification_type,
|
||||
)
|
||||
.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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user