Compare commits
266 Commits
ba787f8cd1
...
2918-bug-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc30ed3d8 | ||
|
|
34d6c827c2 | ||
|
|
8b4f30874b | ||
|
|
4fc131de66 | ||
|
|
9d59641562 | ||
|
|
e0cfb38ea0 | ||
|
|
8347bee078 | ||
|
|
d720860eb8 | ||
|
|
a36fd553e9 | ||
|
|
f023500efc | ||
|
|
0c347fe3db | ||
|
|
8a2516b423 | ||
|
|
42c5d6183e | ||
|
|
f765f19c3b | ||
|
|
5ae6cf7abf | ||
|
|
c8be3d37bd | ||
|
|
afe62a70c1 | ||
|
|
6d2e0f4aa9 | ||
|
|
232425558b | ||
|
|
7004a02e96 | ||
|
|
8b0af3fc32 | ||
|
|
d098d11b9e | ||
|
|
16b495003e | ||
|
|
aed9f3e957 | ||
|
|
944b5eaa62 | ||
|
|
2c372b0464 | ||
|
|
524e8c87e8 | ||
|
|
4a14bf6f89 | ||
|
|
0fea345c8e | ||
|
|
5d83cc43b2 | ||
|
|
3d7aee19d2 | ||
|
|
a85a540abd | ||
|
|
d64766af32 | ||
|
|
c916b9e752 | ||
|
|
753cb0b132 | ||
|
|
f5f6a95937 | ||
|
|
180f372889 | ||
|
|
a5d2310253 | ||
|
|
de16e760ea | ||
|
|
31e72ea24b | ||
|
|
f1b87f56ca | ||
|
|
59a391f15b | ||
|
|
30e4e9c9dd | ||
|
|
2099f1f76b | ||
|
|
4181adb72d | ||
|
|
7ecb69a39f | ||
|
|
d5df4316e4 | ||
|
|
ffe149937e | ||
|
|
afd11b4da9 | ||
|
|
4fa7f1fd25 | ||
|
|
d724cc382e | ||
|
|
5e8da220ec | ||
|
|
ece78d47a6 | ||
|
|
181ab3c4c9 | ||
|
|
66313fe1a6 | ||
|
|
a6c4ccc280 | ||
|
|
497017769d | ||
|
|
6de72191bc | ||
|
|
b2a432c2d1 | ||
|
|
92a684e9a0 | ||
|
|
a5141dae18 | ||
|
|
1d11ad21d8 | ||
|
|
eeedbe869c | ||
|
|
810509711d | ||
|
|
5e95d67cbd | ||
|
|
7781a16ea4 | ||
|
|
4f75d9d3b1 | ||
|
|
77765e8d31 | ||
|
|
69bade1d84 | ||
|
|
d94f324b82 | ||
|
|
a80c6b03d2 | ||
|
|
8ec5178fb3 | ||
|
|
83ae4d2b47 | ||
|
|
282717d2f2 | ||
|
|
1375ca7cfa | ||
|
|
be3297aac8 | ||
|
|
29a9dc5026 | ||
|
|
f1b0a60899 | ||
|
|
e906512d27 | ||
|
|
8b64c0dff0 | ||
|
|
910af35b59 | ||
|
|
8f37e7e618 | ||
|
|
8c5cce8190 | ||
|
|
258452dcbe | ||
|
|
d849a39a7e | ||
|
|
76539e5bde | ||
|
|
2e85677bb0 | ||
|
|
15dd131096 | ||
|
|
fe33285e9d | ||
|
|
4429fc69a6 | ||
|
|
0c63c4620e | ||
|
|
a797e44874 | ||
|
|
9e2dc55500 | ||
|
|
37a86ecb31 | ||
|
|
28af41f72c | ||
|
|
b2f86e045f | ||
|
|
7cbd0602e6 | ||
|
|
63befd58f9 | ||
|
|
6d9d147438 | ||
|
|
c6da0448fa | ||
|
|
54d14844fe | ||
|
|
735b657db1 | ||
|
|
86e6ebe1dd | ||
|
|
78d1b42ede | ||
|
|
377595bf58 | ||
|
|
6eac43b25e | ||
|
|
1740c57f08 | ||
|
|
728038f161 | ||
|
|
b1b63c7d5e | ||
|
|
ca235f12fd | ||
|
|
c22e029eb3 | ||
|
|
74c1648c8d | ||
|
|
808aeabbe1 | ||
|
|
ce11464510 | ||
|
|
b1727e5742 | ||
|
|
00497b918d | ||
|
|
3d784d4d79 | ||
|
|
cbdff04cca | ||
|
|
da8abac30c | ||
|
|
5e4ae34bce | ||
|
|
b5f181a271 | ||
|
|
0cb1060659 | ||
|
|
fd90394dcd | ||
|
|
8d33f28b76 | ||
|
|
20db7929b5 | ||
|
|
a48269d210 | ||
|
|
748c35d2df | ||
|
|
a59b820995 | ||
|
|
e202ab0eb7 | ||
|
|
bf9d61e537 | ||
|
|
a12ed166d5 | ||
|
|
d65f3feaaf | ||
|
|
d678ea857c | ||
|
|
1ea5797807 | ||
|
|
95428f32c7 | ||
|
|
1a6b061657 | ||
|
|
b3cb1c4405 | ||
|
|
012c47eb80 | ||
|
|
30c715bfd9 | ||
|
|
9207bd6876 | ||
|
|
bc3657d566 | ||
|
|
dbfeb32eb2 | ||
|
|
2853a49f0c | ||
|
|
0a25d75146 | ||
|
|
f5bfce516b | ||
|
|
e8fb7117c0 | ||
|
|
24f2cb3566 | ||
|
|
0a11838a73 | ||
|
|
7afcebfc87 | ||
|
|
5c00ee1840 | ||
|
|
bd9948ed7b | ||
|
|
b6b8223eb2 | ||
|
|
c779f38258 | ||
|
|
4f82c69323 | ||
|
|
e4929c835e | ||
|
|
9739fe8d24 | ||
|
|
736e7c34f9 | ||
|
|
43aa02d6f2 | ||
|
|
c4c1cc6046 | ||
|
|
d774ab89d1 | ||
|
|
28cd113f37 | ||
|
|
894e8c7d1b | ||
|
|
843699061d | ||
|
|
7fc6be815a | ||
|
|
562a57bd85 | ||
|
|
511b0b8b9e | ||
|
|
463be2a46b | ||
|
|
95421c6d1e | ||
|
|
12cd3d711e | ||
|
|
26a06e0f4d | ||
|
|
068a7b9a3d | ||
|
|
129f75e045 | ||
|
|
8feaff29ae | ||
|
|
bca68e06c5 | ||
|
|
69eb8e09b2 | ||
|
|
281c1e7e3a | ||
|
|
8ecf72b74f | ||
|
|
3ecd9853ab | ||
|
|
aa18d1447e | ||
|
|
1203546a97 | ||
|
|
cb84fba9f6 | ||
|
|
1b52da73b7 | ||
|
|
1168e221df | ||
|
|
51c9cf9e74 | ||
|
|
c04d8969f5 | ||
|
|
307e52a7d6 | ||
|
|
bf171ebaab | ||
|
|
5e4e763119 | ||
|
|
611ada696f | ||
|
|
55340dbc4b | ||
|
|
29d050a0b8 | ||
|
|
6942a2843d | ||
|
|
ab7b262c01 | ||
|
|
6a3d9c40b4 | ||
|
|
43b68f9337 | ||
|
|
690c164c21 | ||
|
|
59f6cccbed | ||
|
|
2d7271e55b | ||
|
|
b8ec2e03f0 | ||
|
|
e1b530f16c | ||
|
|
b1e5b8d110 | ||
|
|
31a9e39846 | ||
|
|
e3901f80f8 | ||
|
|
7d5db34d16 | ||
|
|
4f489c1445 | ||
|
|
be93596075 | ||
|
|
9e97a797b9 | ||
|
|
a81742c5c5 | ||
|
|
1a5c56b621 | ||
|
|
4c258bb39b | ||
|
|
d35c7bbd5f | ||
|
|
e82e138771 | ||
|
|
e3910e008c | ||
|
|
902e3e85f7 | ||
|
|
f3df2f9c33 | ||
|
|
177f7e836e | ||
|
|
260a58986b | ||
|
|
7e789b1b55 | ||
|
|
a8dd322b82 | ||
|
|
35361fa784 | ||
|
|
3db62a1921 | ||
|
|
bf9d17d660 | ||
|
|
f85efba737 | ||
|
|
c1b071adea | ||
|
|
f69e3a5a00 | ||
|
|
c2b7575e99 | ||
|
|
9d072e44df | ||
|
|
8593a9b1ba | ||
|
|
e5885f674b | ||
|
|
d65be41da5 | ||
|
|
eac2733a33 | ||
|
|
18a6730a2c | ||
|
|
c8a559f0e5 | ||
|
|
d7568eda42 | ||
|
|
0860d41042 | ||
|
|
5a37482510 | ||
|
|
cd14024627 | ||
|
|
d16bfb39ba | ||
|
|
cb94d26b73 | ||
|
|
814d08af2b | ||
|
|
c4842e9e06 | ||
|
|
99585ae9f0 | ||
|
|
411854b481 | ||
|
|
6f663c83eb | ||
|
|
a6f52fa009 | ||
|
|
ee47610d2a | ||
|
|
05381a5ba9 | ||
|
|
32c35eb5f5 | ||
|
|
105cab2314 | ||
|
|
8d6ebe762a | ||
|
|
d8a98dd5d5 | ||
|
|
17ca0cee62 | ||
|
|
0f1d6c698c | ||
|
|
033895cf42 | ||
|
|
b5b0d23b7e | ||
|
|
fa2e5becea | ||
|
|
64cd520b8e | ||
|
|
9f3855270d | ||
|
|
206e76a417 | ||
|
|
265e564b49 | ||
|
|
b15decc8fb | ||
|
|
99194c08d0 | ||
|
|
6b23ea46e7 | ||
|
|
32f8a31685 | ||
|
|
ed06cf5695 | ||
|
|
6034dcdb12 |
12
.ds.baseline
@@ -161,7 +161,7 @@
|
||||
"filename": "app/config.py",
|
||||
"hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc",
|
||||
"is_verified": false,
|
||||
"line_number": 123,
|
||||
"line_number": 122,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
@@ -527,7 +527,7 @@
|
||||
"filename": "tests/app/main/views/test_register.py",
|
||||
"hashed_secret": "bdbb156d25d02fd7792865824201dda1c60f4473",
|
||||
"is_verified": false,
|
||||
"line_number": 115,
|
||||
"line_number": 118,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
@@ -535,7 +535,7 @@
|
||||
"filename": "tests/app/main/views/test_register.py",
|
||||
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||
"is_verified": false,
|
||||
"line_number": 185,
|
||||
"line_number": 188,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
@@ -543,7 +543,7 @@
|
||||
"filename": "tests/app/main/views/test_register.py",
|
||||
"hashed_secret": "bb5b7caa27d005d38039e3797c3ddb9bcd22c3c8",
|
||||
"is_verified": false,
|
||||
"line_number": 256,
|
||||
"line_number": 259,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
@@ -629,10 +629,10 @@
|
||||
"filename": "tests/conftest.py",
|
||||
"hashed_secret": "f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea",
|
||||
"is_verified": false,
|
||||
"line_number": 3267,
|
||||
"line_number": 3287,
|
||||
"is_secret": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2025-07-22T17:07:31Z"
|
||||
"generated_at": "2025-09-09T19:57:05Z"
|
||||
}
|
||||
|
||||
33
.github/ISSUE_TEMPLATE/manual_qa_template.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name: "QA Checklist"
|
||||
description: "Use this template to manually QA for prod push"
|
||||
title: "QA for prod push: Admin #[insert PR number], API #[insert PR number], Utils #[insert PR number]"
|
||||
title: "QA checklist for production release: [insert planned release date here]"
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: Logging_in
|
||||
id: sign_in
|
||||
attributes:
|
||||
label: Log In
|
||||
label: Sign In
|
||||
options:
|
||||
- label: notify-staging.app.cloud.gov resolves
|
||||
- label: Clicking sign in takes you to Login.gov
|
||||
@@ -25,9 +25,8 @@ body:
|
||||
- label: Formatting looks clean and in place
|
||||
- label: Message dashboard populates and shows “pending” until call is made to AWS for delivery receipt
|
||||
|
||||
|
||||
- type: checkboxes
|
||||
id: send__copied_template
|
||||
id: send_copied_template
|
||||
attributes:
|
||||
label: Send a message from copied template
|
||||
options:
|
||||
@@ -37,7 +36,7 @@ body:
|
||||
|
||||
|
||||
- type: checkboxes
|
||||
id: send__existing_template
|
||||
id: send_existing_template
|
||||
attributes:
|
||||
label: Send a message from existing template
|
||||
options:
|
||||
@@ -45,13 +44,25 @@ body:
|
||||
- label: All else above persists as true (formatting, send, dashboard, etc)
|
||||
|
||||
- type: checkboxes
|
||||
id: download_report
|
||||
id: template_folder_permissions
|
||||
attributes:
|
||||
label: Download the csv delivery report
|
||||
label: Checking template folder permissions
|
||||
options:
|
||||
- label: Clicking on the csv download directly after a message send downloads a report that contains the data we expect
|
||||
- label: The batch report appears on the main dashboard batch table with accurate information
|
||||
- label: Clicking on the csv download link from the main dashboard downloads a report that contains the data we expect
|
||||
- label: Only the template creator and platform admins should be able to see a new template folder
|
||||
- label: Adding a non platform admin user to a folder should grant them access to see and copy the templates
|
||||
- label: Removing a non platform admin user to a folder should remove their access to see and copy the templates
|
||||
- label: Platform admins should not be able to be added or removed from template folders, since they have full access regardless
|
||||
|
||||
- type: checkboxes
|
||||
id: download_reports
|
||||
attributes:
|
||||
label: Download the CSV delivery reports
|
||||
options:
|
||||
- label: Clicking on the CSV download directly after a message send downloads a report that contains the data we expect
|
||||
- label: Clicking on the CSV download links from the main dashboard downloads a report that contains the data we expect
|
||||
- label: The daily generated batch reports appear at the bottom of the main dashboard batch table with accurate information
|
||||
- label: The daily generated batch report buttons are highlighted appropriately based on which reports are available (1, 3, 5, and 7 day)
|
||||
- label: Downloading the generated batch reports that are available work properly and contain the data that we expect
|
||||
|
||||
- type: checkboxes
|
||||
id: invite_member
|
||||
|
||||
4
.github/actions/setup-project/action.yml
vendored
@@ -9,10 +9,10 @@ runs:
|
||||
sudo apt-get update \
|
||||
&& sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev
|
||||
- name: Set up Python 3.12.9
|
||||
- name: Set up Python 3.13.2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12.9"
|
||||
python-version: "3.13.2"
|
||||
- name: Install poetry
|
||||
shell: bash
|
||||
run: pip install poetry==2.1.3
|
||||
|
||||
4
.github/workflows/checks.yml
vendored
@@ -100,9 +100,9 @@ jobs:
|
||||
# tail -f admin-server.log &
|
||||
# above make e2e-test
|
||||
|
||||
|
||||
run: |
|
||||
make run-flask > admin-server.log 2>&1 &
|
||||
make wait-for-flask
|
||||
tail -f admin-server.log &
|
||||
make e2e-test
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
NOTIFY_E2E_AUTH_STATE_PATH: ${{ secrets.NOTIFY_E2E_AUTH_STATE_PATH }}
|
||||
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
|
||||
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
|
||||
NOTIFY_E2E_TEST_URI: http://localhost:6012/
|
||||
NOTIFY_E2E_TEST_URI: http://localhost:6012
|
||||
VCAP_SERVICES: ${{ secrets.VCAP_SERVICES }}
|
||||
|
||||
validate-new-relic-config:
|
||||
|
||||
23
Makefile
@@ -15,7 +15,7 @@ NVMSH := $(shell [ -f "$(HOME)/.nvm/nvm.sh" ] && echo "$(HOME)/.nvm/nvm.sh" || e
|
||||
## DEVELOPMENT
|
||||
|
||||
.PHONY: bootstrap
|
||||
bootstrap: ## Set up everything to run the app
|
||||
bootstrap: ## Set up everything to run the application
|
||||
make generate-version-file
|
||||
poetry sync --no-root
|
||||
poetry run playwright install --with-deps
|
||||
@@ -44,6 +44,19 @@ watch-frontend: ## Build frontend and watch for changes
|
||||
run-flask: ## Run flask
|
||||
poetry run newrelic-admin run-program flask run -p 6012 --host=0.0.0.0
|
||||
|
||||
.PHONY: wait-for-flask
|
||||
wait-for-flask:
|
||||
@echo "Waiting for Flask to start..."
|
||||
@timeout 30 bash -c 'until curl -sf http://localhost:6012 > /dev/null 2>&1; do sleep 1; done'
|
||||
@echo "Flask is ready!"
|
||||
|
||||
.PHONY: run-flask-and-wait
|
||||
run-flask-and-wait:
|
||||
@make run-flask &
|
||||
@echo "Waiting for Flask to start..."
|
||||
@timeout 30 bash -c 'until curl -sf http://localhost:6012 > /dev/null 2>&1; do sleep 1; done'
|
||||
@echo "Flask is ready!"
|
||||
|
||||
.PHONY: run-flask-bare
|
||||
run-flask-bare: ## Run flask without invoking poetry so we can override ENV variables in .env
|
||||
flask run -p 6012 --host=0.0.0.0
|
||||
@@ -67,6 +80,9 @@ generate-version-file: ## Generates the app version file
|
||||
.PHONY: test
|
||||
test: py-lint py-test js-test ## Run tests
|
||||
|
||||
.PHONY: test-fast
|
||||
test-fast: py-lint py-test-fast js-test ## Run tests quickly in parallel (testing locally first)
|
||||
|
||||
.PHONY: py-lint
|
||||
py-lint: ## Run python linting scanners and black
|
||||
poetry self add poetry-dotenv-plugin
|
||||
@@ -98,6 +114,11 @@ py-test: ## Run python unit tests
|
||||
poetry run coverage report --fail-under=93
|
||||
poetry run coverage html -d .coverage_cache
|
||||
|
||||
.PHONY: py-test-fast
|
||||
py-test-fast: export NEW_RELIC_ENVIRONMENT=test
|
||||
py-test-fast: ## Run python unit tests in parallel (testing locally first)
|
||||
poetry run pytest --maxfail=10 --ignore=tests/end_to_end tests/ -n auto
|
||||
|
||||
.PHONY: dead-code
|
||||
dead-code: ## 60% is our aspirational goal, but currently breaks the build
|
||||
poetry run vulture ./app ./notifications_utils --min-confidence=100
|
||||
|
||||
27
README.md
@@ -130,7 +130,7 @@ 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
|
||||
- [tenv](https://github.com/tofuutils/tenv) - 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)
|
||||
@@ -147,15 +147,18 @@ brew install jq git tfenv cloudfoundry/tap/cf-cli@8 vim wget
|
||||
|
||||
#### [API Step] Terraform Installation
|
||||
|
||||
As a part of the installation above, you just installed `tfenv` to manage
|
||||
As a part of the installation above, you just installed `tenv` 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.4.0
|
||||
tenv
|
||||
```
|
||||
|
||||
_NOTE: This project currently uses the latest `1.4.x release of Terraform._
|
||||
This will open a menu for you; choose Terraform, then choose the latest stable
|
||||
version.
|
||||
|
||||
_NOTE: This project currently uses the latest `1.12.x release of Terraform._
|
||||
|
||||
#### [API Step] Python Installation
|
||||
|
||||
@@ -186,12 +189,12 @@ session to make the changes take effect.
|
||||
Now we're ready to install the Python version we need with `pyenv`, like so:
|
||||
|
||||
```sh
|
||||
pyenv install 3.12
|
||||
pyenv install 3.13
|
||||
```
|
||||
|
||||
This will install the latest version of Python 3.12.
|
||||
This will install the latest version of Python 3.13.
|
||||
|
||||
_NOTE: This project currently runs on Python 3.12.x._
|
||||
_NOTE: This project currently runs on Python 3.13.x._
|
||||
|
||||
#### [API Step] Python Dependency Installation
|
||||
|
||||
@@ -243,12 +246,12 @@ git clone git@github.com:GSA/notifications-admin.git
|
||||
|
||||
Now go into the project directory (`notifications-admin` by default), create a
|
||||
virtual environment, and set the local Python version to point to the virtual
|
||||
environment (assumes version Python `3.12.9` is what is installed on your
|
||||
environment (assumes version Python `3.13.2` is what is installed on your
|
||||
machine):
|
||||
|
||||
```sh
|
||||
cd notifications-admin
|
||||
pyenv virtualenv 3.12.9 notify-admin
|
||||
pyenv virtualenv 3.13.2 notify-admin
|
||||
pyenv local notify-admin
|
||||
```
|
||||
|
||||
@@ -281,10 +284,10 @@ 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:
|
||||
we'll use `3.13` in our example here since we recently upgraded to this version:
|
||||
|
||||
```sh
|
||||
pyenv install 3.12
|
||||
pyenv install 3.13
|
||||
```
|
||||
|
||||
Next, delete the virtual environment you previously had set up. If you followed
|
||||
@@ -299,7 +302,7 @@ environment with the newer version of Python you just installed:
|
||||
|
||||
```sh
|
||||
cd notifications-admin
|
||||
pyenv virtualenv 3.12.9 notify-admin
|
||||
pyenv virtualenv 3.13.2 notify-admin
|
||||
pyenv local notify-admin
|
||||
```
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from functools import partial
|
||||
from time import monotonic
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
@@ -101,11 +102,17 @@ from app.notify_client.organizations_api_client import organizations_client
|
||||
from app.notify_client.performance_dashboard_api_client import (
|
||||
performance_dashboard_api_client,
|
||||
)
|
||||
from app.notify_client.platform_stats_api_client import platform_stats_api_client
|
||||
from app.notify_client.platform_stats_api_client import (
|
||||
platform_stats_api_client,
|
||||
)
|
||||
from app.notify_client.service_api_client import service_api_client
|
||||
from app.notify_client.status_api_client import status_api_client
|
||||
from app.notify_client.template_folder_api_client import template_folder_api_client
|
||||
from app.notify_client.template_statistics_api_client import template_statistics_client
|
||||
from app.notify_client.template_folder_api_client import (
|
||||
template_folder_api_client,
|
||||
)
|
||||
from app.notify_client.template_statistics_api_client import (
|
||||
template_statistics_client,
|
||||
)
|
||||
from app.notify_client.upload_api_client import upload_api_client
|
||||
from app.notify_client.user_api_client import user_api_client
|
||||
from app.url_converters import SimpleDateTypeConverter, TemplateTypeConverter
|
||||
@@ -141,7 +148,6 @@ navigation = {
|
||||
|
||||
def _csp(config):
|
||||
asset_domain = config["ASSET_DOMAIN"]
|
||||
logo_domain = config["LOGO_CDN_DOMAIN"]
|
||||
api_public_url = config["API_PUBLIC_URL"]
|
||||
api_public_ws_url = config["API_PUBLIC_WS_URL"]
|
||||
|
||||
@@ -157,7 +163,6 @@ def _csp(config):
|
||||
"script-src": [
|
||||
"'self'",
|
||||
asset_domain,
|
||||
"'unsafe-eval'",
|
||||
"https://js-agent.newrelic.com",
|
||||
"https://gov-bam.nr-data.net",
|
||||
"https://www.googletagmanager.com",
|
||||
@@ -165,25 +170,22 @@ def _csp(config):
|
||||
"https://dap.digitalgov.gov",
|
||||
"https://cdn.socket.io",
|
||||
],
|
||||
"connect-src": [
|
||||
"connect-src": list(dict.fromkeys([
|
||||
"'self'",
|
||||
"https://gov-bam.nr-data.net",
|
||||
"https://www.google-analytics.com",
|
||||
f"{api_public_url}",
|
||||
f"{api_public_ws_url}",
|
||||
],
|
||||
])),
|
||||
"style-src": ["'self'", asset_domain],
|
||||
"img-src": ["'self'", asset_domain, logo_domain],
|
||||
"img-src": ["'self'", asset_domain],
|
||||
}
|
||||
|
||||
|
||||
def create_app(application):
|
||||
@application.after_request
|
||||
def add_csp_header(response):
|
||||
existing_csp = response.headers.get("Content-Security-Policy", "")
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
existing_csp + "; form-action 'self';"
|
||||
)
|
||||
def add_security_headers(response):
|
||||
response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
|
||||
return response
|
||||
|
||||
@application.context_processor
|
||||
@@ -271,14 +273,11 @@ def create_app(application):
|
||||
content_security_policy=_csp(application.config),
|
||||
content_security_policy_nonce_in=["style-src", "script-src"],
|
||||
permissions_policy={
|
||||
"accelerometer": "()",
|
||||
"ambient-light-sensor": "()",
|
||||
"autoplay": "()",
|
||||
"battery": "()",
|
||||
"accelerometer": '(self "https://www.youtube-nocookie.com")',
|
||||
"autoplay": '(self "https://www.youtube-nocookie.com")',
|
||||
"camera": "()",
|
||||
"document-domain": "()",
|
||||
"geolocation": "()",
|
||||
"gyroscope": "()",
|
||||
"gyroscope": '(self "https://www.youtube-nocookie.com")',
|
||||
"local-fonts": "()",
|
||||
"magnetometer": "()",
|
||||
"microphone": "()",
|
||||
@@ -539,26 +538,28 @@ def register_errorhandlers(application): # noqa (C901 too complex)
|
||||
|
||||
@application.errorhandler(HTTPError)
|
||||
def render_http_error(error):
|
||||
error_url = error.response.url if error.response else "unknown URL"
|
||||
|
||||
application.logger.warning(
|
||||
"API {} failed with status {} message {}".format(
|
||||
error.response.url if error.response else "unknown",
|
||||
error.status_code,
|
||||
error.message,
|
||||
)
|
||||
f"API {error_url} failed with status {error.status_code} message {error.message}",
|
||||
exc_info=sys.exc_info(),
|
||||
stack_info=True,
|
||||
)
|
||||
|
||||
error_code = error.status_code
|
||||
|
||||
if error_code not in [401, 404, 403, 410]:
|
||||
# probably a 500 or 503.
|
||||
# it might be a 400, which we should handle as if it's an internal server error. If the API might
|
||||
# legitimately return a 400, we should handle that within the view or the client that calls it.
|
||||
application.logger.exception(
|
||||
"API {} failed with status {} message {}".format(
|
||||
error.response.url if error.response else "unknown",
|
||||
error.status_code,
|
||||
error.message,
|
||||
)
|
||||
f"API {error_url} failed with status {error.status_code} message {error.message}",
|
||||
exc_info=sys.exc_info(),
|
||||
stack_info=True,
|
||||
)
|
||||
|
||||
error_code = 500
|
||||
|
||||
return _error_response(error_code)
|
||||
|
||||
@application.errorhandler(400)
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
const tableContainer = document.getElementById('activityContainer');
|
||||
const currentUserName = tableContainer.getAttribute('data-currentUserName');
|
||||
const currentServiceId = tableContainer.getAttribute('data-currentServiceId');
|
||||
let pollInterval;
|
||||
let isPolling = false;
|
||||
const POLL_INTERVAL_MS = 25000;
|
||||
const COLORS = {
|
||||
delivered: '#0076d6',
|
||||
failed: '#fa9441',
|
||||
@@ -153,8 +156,6 @@
|
||||
.on('mouseout', function() {
|
||||
tooltip.style('display', 'none');
|
||||
})
|
||||
.transition()
|
||||
.duration(1000)
|
||||
.attr('y', d => y(d[1]))
|
||||
.attr('height', d => {
|
||||
const calculatedHeight = y(d[0]) - y(d[1]);
|
||||
@@ -209,28 +210,35 @@
|
||||
table.append(tbody);
|
||||
};
|
||||
|
||||
const fetchData = function(type) {
|
||||
const fetchData = async function(type) {
|
||||
if (isPolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
var ctx = document.getElementById('weeklyChart');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling = true;
|
||||
|
||||
var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
var url = type === 'service'
|
||||
? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}`
|
||||
: `/services/${currentServiceId}/daily-stats-by-user.json?timezone=${encodeURIComponent(userTimezone)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
return fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const data = await response.json();
|
||||
labels = [];
|
||||
deliveredData = [];
|
||||
failedData = [];
|
||||
@@ -277,10 +285,41 @@
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch(error => console.error('Error fetching daily stats:', error));
|
||||
};
|
||||
setInterval(() => fetchData(currentType), 25000);
|
||||
} catch (error) {
|
||||
console.error('Error fetching daily stats:', error);
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
function startPolling() {
|
||||
fetchData(currentType);
|
||||
|
||||
pollInterval = setInterval(() => {
|
||||
fetchData(currentType);
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof jest === 'undefined') {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
const handleDropdownChange = function(event) {
|
||||
const selectedValue = event.target.value;
|
||||
currentType = selectedValue;
|
||||
@@ -295,37 +334,50 @@
|
||||
liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`;
|
||||
|
||||
const tableHeading = document.querySelector('#tableActivity h2');
|
||||
const senderColumns = document.querySelectorAll('.sender-column');
|
||||
const senderElements = [
|
||||
document.querySelector('[data-column="sender"]'),
|
||||
...document.querySelectorAll('[data-sender]')
|
||||
];
|
||||
const allRows = document.querySelectorAll('#activity-table tbody tr');
|
||||
const caption = document.querySelector('#activity-table caption');
|
||||
const table = document.getElementById('activity-table');
|
||||
const caption = table.querySelector('caption');
|
||||
|
||||
if (selectedValue === 'individual') {
|
||||
|
||||
tableHeading.textContent = 'My activity';
|
||||
caption.textContent = `Table showing the sent jobs for ${currentUserName}`;
|
||||
|
||||
senderColumns.forEach(col => {
|
||||
col.style.display = 'none';
|
||||
senderElements.forEach(el => {
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
|
||||
allRows.forEach(row => row.style.display = 'none');
|
||||
|
||||
const userRows = Array.from(allRows).filter(row => {
|
||||
const senderCell = row.querySelector('.sender-column');
|
||||
const rowSender = senderCell ? senderCell.textContent.trim() : '';
|
||||
const senderCell = row.querySelector('[data-sender]');
|
||||
const rowSender = senderCell ? senderCell.dataset.sender : '';
|
||||
return rowSender === currentUserName;
|
||||
});
|
||||
|
||||
userRows.slice(0, 5).forEach(row => {
|
||||
row.style.display = '';
|
||||
});
|
||||
if (userRows.length > 0) {
|
||||
userRows.slice(0, 5).forEach(row => {
|
||||
row.style.display = '';
|
||||
});
|
||||
} else {
|
||||
const emptyMessageRow = Array.from(allRows).find(row => {
|
||||
return row.querySelector('.table-empty-message');
|
||||
});
|
||||
if (emptyMessageRow) {
|
||||
emptyMessageRow.style.display = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
tableHeading.textContent = 'Service activity';
|
||||
caption.textContent = `Table showing the sent jobs for service`;
|
||||
|
||||
senderColumns.forEach(col => {
|
||||
col.style.display = '';
|
||||
senderElements.forEach(el => {
|
||||
if (el) el.style.display = '';
|
||||
});
|
||||
|
||||
allRows.forEach((row, index) => {
|
||||
|
||||
@@ -118,7 +118,8 @@
|
||||
CollapsibleCheckboxes.prototype.addHeadingHideLegend = function() {
|
||||
const headingLevel = this.$component.data('heading-level') || '2';
|
||||
|
||||
this.$heading = $(`<h${headingLevel} class="heading-small">${this.legendText}</h${headingLevel}>`);
|
||||
this.$heading = $(`<h${headingLevel} class="heading-small"></h${headingLevel}>`);
|
||||
this.$heading.text(this.legendText);
|
||||
this.$fieldset.before(this.$heading);
|
||||
|
||||
this.$fieldset.find('legend').addClass('usa-sr-only');
|
||||
@@ -138,6 +139,7 @@
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.toggleAll = function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const allChecked = this.$checkboxes.filter(':checked').length === this.$checkboxes.length;
|
||||
|
||||
if (allChecked) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
this.minEntries = 2;
|
||||
this.listItemName = this.$wrapper.data('listItemName');
|
||||
this.getSharedAttributes();
|
||||
this.getOriginalClasses();
|
||||
|
||||
this.getValues();
|
||||
this.maxEntries = this.entries.length;
|
||||
@@ -25,31 +26,33 @@
|
||||
this.bindEvents();
|
||||
};
|
||||
ListEntry.optionalAttributes = ['aria-describedby'];
|
||||
ListEntry.prototype.entryTemplate = Hogan.compile(
|
||||
'<div class="list-entry">' +
|
||||
'<label for="{{{id}}}" class="govuk-input--numbered__label">' +
|
||||
'<span class="usa-sr-only">{{listItemName}} number </span>{{number}}.' +
|
||||
'</label>' +
|
||||
'<input' +
|
||||
' name="{{name}}"' +
|
||||
' id="{{id}}"' +
|
||||
' {{#value}}value="{{value}}{{/value}}"' +
|
||||
' {{{sharedAttributes}}}' +
|
||||
'/>' +
|
||||
'{{#button}}' +
|
||||
'<button type="button" class="usa-button input-list__button--remove">' +
|
||||
'Remove<span class="usa-sr-only"> {{listItemName}} number {{number}}</span>' +
|
||||
'</button>' +
|
||||
'{{/button}}' +
|
||||
'</div>'
|
||||
);
|
||||
ListEntry.prototype.addButtonTemplate = Hogan.compile(
|
||||
'<button type="button" class="usa-button input-list__button--add">Add another {{listItemName}} ({{entriesLeft}} remaining)</button>'
|
||||
);
|
||||
ListEntry.prototype.renderEntry = function(data) {
|
||||
return `
|
||||
<div class="list-entry">
|
||||
<label for="${data.id}" class="usa-label">
|
||||
<span class="usa-sr-only">${data.listItemName} number </span>${data.number}.
|
||||
</label>
|
||||
<input
|
||||
class="usa-input ${data.classes || ''}"
|
||||
name="${data.name}"
|
||||
id="${data.id}"
|
||||
${data.value ? `value="${data.value}"` : ''}
|
||||
${data.sharedAttributes}
|
||||
/>
|
||||
${data.button ? `
|
||||
<button type="button" class="usa-button usa-button--unstyled input-list__button--remove">
|
||||
Remove<span class="usa-sr-only"> ${data.listItemName} number ${data.number}</span>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
ListEntry.prototype.renderAddButton = function(data) {
|
||||
return `<button type="button" class="usa-button usa-button--outline input-list__button--add margin-top-4">Add another ${data.listItemName} (${data.entriesLeft} remaining)</button>`;
|
||||
};
|
||||
ListEntry.prototype.getSharedAttributes = function () {
|
||||
var $inputs = this.$wrapper.find('input'),
|
||||
attributeTemplate = Hogan.compile(' {{name}}="{{value}}"'),
|
||||
generatedAttributes = ['id', 'name', 'value'],
|
||||
generatedAttributes = ['id', 'name', 'value', 'class'],
|
||||
attributes = [],
|
||||
attrIdx,
|
||||
elmAttributes,
|
||||
@@ -68,7 +71,7 @@
|
||||
while (attrIdx--) {
|
||||
// prevent duplicates
|
||||
if ($.inArray(elmAttrs[attrIdx].name, existingAttributes) === -1) {
|
||||
attrStr += attributeTemplate.render({ 'name': elmAttrs[attrIdx].name, 'value': elmAttrs[attrIdx].value });
|
||||
attrStr += ` ${elmAttrs[attrIdx].name}="${elmAttrs[attrIdx].value}"`;
|
||||
existingAttributes.push(elmAttrs[attrIdx].name);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +97,20 @@
|
||||
|
||||
this.sharedAttributes = (attributes.length) ? getAttributesHTML(attributes) : '';
|
||||
};
|
||||
ListEntry.prototype.getOriginalClasses = function () {
|
||||
var $firstInput = this.$wrapper.find('input').first();
|
||||
if ($firstInput.length) {
|
||||
var classList = $firstInput.attr('class');
|
||||
if (classList) {
|
||||
// Preserve any additional classes from the original input
|
||||
this.additionalClasses = classList;
|
||||
} else {
|
||||
this.additionalClasses = '';
|
||||
}
|
||||
} else {
|
||||
this.additionalClasses = '';
|
||||
}
|
||||
};
|
||||
ListEntry.prototype.getValues = function () {
|
||||
this.entries = [];
|
||||
this.$wrapper.find('input').each(function (idx, elm) {
|
||||
@@ -182,16 +199,17 @@
|
||||
'name' : this.getId(entryNumber),
|
||||
'value' : entry,
|
||||
'listItemName' : this.listItemName,
|
||||
'sharedAttributes': this.sharedAttributes
|
||||
'sharedAttributes': this.sharedAttributes,
|
||||
'classes': this.additionalClasses
|
||||
};
|
||||
|
||||
if (entryNumber > 1) {
|
||||
dataObj.button = true;
|
||||
}
|
||||
this.$wrapper.append(this.entryTemplate.render(dataObj));
|
||||
this.$wrapper.append(this.renderEntry(dataObj));
|
||||
}.bind(this));
|
||||
if (this.entries.length < this.maxEntries) {
|
||||
this.$wrapper.append(this.addButtonTemplate.render({
|
||||
this.$wrapper.append(this.renderAddButton({
|
||||
'listItemName' : this.listItemName,
|
||||
'entriesLeft' : (this.maxEntries - this.entries.length)
|
||||
}));
|
||||
|
||||
@@ -7,9 +7,6 @@ $(() => GOVUK.modules.start());
|
||||
|
||||
$(() => $('.error-message, .usa-error-message').eq(0).parent('label').next('input').trigger('focus'));
|
||||
|
||||
$(() => $('.govuk-header__container').on('click', function() {
|
||||
$(this).css('border-color', '#005ea5');
|
||||
}));
|
||||
|
||||
// Applies our expanded focus style to the siblings of links when that link is wrapped in a heading.
|
||||
//
|
||||
|
||||
@@ -14,7 +14,6 @@ import Button from 'govuk-frontend/components/button/button';
|
||||
import Radios from 'govuk-frontend/components/radios/radios';
|
||||
|
||||
// Modules from 3rd party vendors
|
||||
import morphdom from 'morphdom';
|
||||
|
||||
/**
|
||||
* TODO: Ideally this would be a NodeList.prototype.forEach polyfill
|
||||
@@ -67,13 +66,8 @@ var Frontend = {
|
||||
"initAll": initAll
|
||||
}
|
||||
|
||||
var vendor = {
|
||||
"morphdom": morphdom
|
||||
}
|
||||
|
||||
// The exported object will be assigned to window.GOVUK in our production code
|
||||
// (bundled into an IIFE by RollupJS)
|
||||
export {
|
||||
Frontend,
|
||||
vendor
|
||||
Frontend
|
||||
}
|
||||
|
||||
@@ -3,65 +3,70 @@
|
||||
"use strict";
|
||||
|
||||
var Modules = global.GOVUK.Modules;
|
||||
var Hogan = global.Hogan;
|
||||
|
||||
// Object holding all the states for the component's HTML
|
||||
let states = {
|
||||
'initial': Hogan.compile(`
|
||||
{{#showNowAsDefault}}
|
||||
// Template functions for rendering component states
|
||||
let renderStates = {
|
||||
'initial': function(data) {
|
||||
return `
|
||||
${data.showNowAsDefault ? `
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" id="${data.name}-0" name="${data.name}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${data.name}-0">Now</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" id="{{name}}-0" name="{{name}}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="{{name}}-0">Now</label>
|
||||
</div>
|
||||
${data.categories.map(category =>
|
||||
`<input type='button' class='usa-button usa-button--outline radio-select__button--category' aria-expanded="false" value='${category}' />`
|
||||
).join('')}
|
||||
</div>
|
||||
{{/showNowAsDefault}}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
{{#categories}}
|
||||
<input type='button' class='usa-button usa-button--outline radio-select__button--category' aria-expanded="false" value='{{.}}' />
|
||||
{{/categories}}
|
||||
</div>
|
||||
`),
|
||||
'choose': Hogan.compile(`
|
||||
{{#showNowAsDefault}}
|
||||
`;
|
||||
},
|
||||
'choose': function(data) {
|
||||
return `
|
||||
${data.showNowAsDefault ? `
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" id="${data.name}-0" name="${data.name}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${data.name}-0">Now</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" id="{{name}}-0" name="{{name}}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="{{name}}-0">Now</label>
|
||||
</div>
|
||||
${data.choices.map(choice => `
|
||||
<div class="usa-radio js-option">
|
||||
<input class="usa-radio__input" type="radio" value="${choice.value}" id="${choice.id}" name="${data.name}" />
|
||||
<label class="usa-radio__label" for="${choice.id}">${choice.label}</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
<input type='button' class='usa-button usa-button--outline radio-select__button--done margin-top-4' aria-expanded='true' value='Back to select a new time' />
|
||||
</div>
|
||||
{{/showNowAsDefault}}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
{{#choices}}
|
||||
<div class="usa-radio js-option">
|
||||
<input class="usa-radio__input" type="radio" value="{{value}}" id="{{id}}" name="{{name}}" />
|
||||
<label class="usa-radio__label" for="{{id}}">{{label}}</label>
|
||||
`;
|
||||
},
|
||||
'chosen': function(data) {
|
||||
return `
|
||||
${data.showNowAsDefault ? `
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" id="${data.name}-0" name="${data.name}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${data.name}-0">Now</label>
|
||||
</div>
|
||||
</div>
|
||||
{{/choices}}
|
||||
<input type='button' class='usa-button usa-button--outline radio-select__button--done margin-top-4' aria-expanded='true' value='Back to select a new time' />
|
||||
</div>
|
||||
`),
|
||||
'chosen': Hogan.compile(`
|
||||
{{#showNowAsDefault}}
|
||||
` : ''}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" id="{{name}}-0" name="{{name}}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="{{name}}-0">Now</label>
|
||||
</div>
|
||||
${data.choices.map(choice => `
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" type="radio" value="${choice.value}" id="${choice.id}" name="${data.name}" />
|
||||
<label class="usa-radio__label" for="${choice.id}">${choice.label}</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
{{/showNowAsDefault}}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
{{#choices}}
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" type="radio" value="{{value}}" id="{{id}}" name="{{name}}" />
|
||||
<label class="usa-radio__label" for="{{id}}">{{label}}</label>
|
||||
</div>
|
||||
{{/choices}}
|
||||
</div>
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<input type='button' class='usa-button usa-button--outline radio-select__button--reset' aria-expanded='false' value='Choose a different time' />
|
||||
</div>
|
||||
`)
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<input type='button' class='usa-button usa-button--outline radio-select__button--reset' aria-expanded='false' value='Choose a different time' />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
let shiftFocus = function(elementToFocus, component) {
|
||||
@@ -80,7 +85,7 @@
|
||||
|
||||
let $component = $(component);
|
||||
let render = (state, data) => {
|
||||
$component.html(states[state].render(data));
|
||||
$component.html(renderStates[state](data));
|
||||
};
|
||||
// store array of all options in component
|
||||
let choices = $('label', $component).toArray().map(function(element) {
|
||||
|
||||
13
app/assets/javascripts/scrollPosition.js
Normal file
@@ -0,0 +1,13 @@
|
||||
document.querySelectorAll('.usa-button-group a, .usa-pagination a').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
sessionStorage.setItem('scrollPosition', window.pageYOffset);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var scrollPosition = sessionStorage.getItem('scrollPosition');
|
||||
if (scrollPosition !== null) {
|
||||
window.scrollTo(0, parseInt(scrollPosition));
|
||||
sessionStorage.removeItem('scrollPosition');
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,3 @@
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function (...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const isJobPage = window.location.pathname.includes('/jobs/');
|
||||
if (!isJobPage) return;
|
||||
@@ -15,69 +7,115 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const featureEnabled = jobEl?.dataset?.feature === 'true';
|
||||
const apiHost = jobEl?.dataset?.host;
|
||||
|
||||
if (!jobId) return;
|
||||
if (!jobId || !featureEnabled) return;
|
||||
|
||||
if (featureEnabled) {
|
||||
const socket = io(apiHost);
|
||||
const DEFAULT_INTERVAL_MS = 10000;
|
||||
const MIN_INTERVAL_MS = 1000;
|
||||
const MAX_INTERVAL_MS = 30000;
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('Socket connect_error:', err);
|
||||
});
|
||||
let pollInterval;
|
||||
let currentInterval = DEFAULT_INTERVAL_MS;
|
||||
let isPolling = false;
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.error('Socket error:', err);
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('join', { room: `job-${jobId}` });
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
socket.emit('leave', { room: `job-${jobId}` });
|
||||
});
|
||||
|
||||
const debouncedUpdate = debounce((data) => {
|
||||
updateAllJobSections();
|
||||
}, 1000);
|
||||
|
||||
socket.on('job_updated', (data) => {
|
||||
if (data.job_id !== jobId) return;
|
||||
debouncedUpdate(data);
|
||||
});
|
||||
function calculateBackoff(responseTime) {
|
||||
return Math.min(
|
||||
MAX_INTERVAL_MS,
|
||||
Math.max(
|
||||
MIN_INTERVAL_MS,
|
||||
Math.floor((250 * Math.sqrt(responseTime)) - 1000)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function updateAllJobSections() {
|
||||
async function updateAllJobSections() {
|
||||
if (isPolling || document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling = true;
|
||||
const startTime = Date.now();
|
||||
|
||||
const resourceEl = document.querySelector('[data-socket-update="status"]');
|
||||
const url = resourceEl?.dataset?.resource;
|
||||
|
||||
if (!url) {
|
||||
console.warn('No resource URL found for job updates');
|
||||
isPolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then(({ status, counts, notifications }) => {
|
||||
const sections = {
|
||||
status: document.querySelector('[data-socket-update="status"]'),
|
||||
counts: document.querySelector('[data-socket-update="counts"]'),
|
||||
notifications: document.querySelector(
|
||||
'[data-socket-update="notifications"]'
|
||||
),
|
||||
};
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (status && sections.status) {
|
||||
sections.status.innerHTML = status;
|
||||
}
|
||||
if (counts && sections.counts) {
|
||||
sections.counts.innerHTML = counts;
|
||||
}
|
||||
if (notifications && sections.notifications) {
|
||||
sections.notifications.innerHTML = notifications;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error fetching job update partials:', err);
|
||||
const sections = {
|
||||
status: document.querySelector('[data-socket-update="status"]'),
|
||||
counts: document.querySelector('[data-socket-update="counts"]'),
|
||||
notifications: document.querySelector('[data-socket-update="notifications"]'),
|
||||
};
|
||||
|
||||
if (data.status && sections.status) {
|
||||
sections.status.innerHTML = data.status;
|
||||
}
|
||||
if (data.counts && sections.counts) {
|
||||
sections.counts.innerHTML = data.counts;
|
||||
}
|
||||
if (data.notifications && sections.notifications) {
|
||||
sections.notifications.innerHTML = data.notifications;
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
currentInterval = calculateBackoff(responseTime);
|
||||
|
||||
if (data.stop === 1 || data.finished === true) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching job updates:', {
|
||||
error: error.message,
|
||||
url: url,
|
||||
jobId: jobId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
currentInterval = Math.min(currentInterval * 2, MAX_INTERVAL_MS);
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
updateAllJobSections();
|
||||
|
||||
function scheduleNext() {
|
||||
if (pollInterval) clearTimeout(pollInterval);
|
||||
pollInterval = setTimeout(() => {
|
||||
updateAllJobSections();
|
||||
scheduleNext();
|
||||
}, currentInterval);
|
||||
}
|
||||
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearTimeout(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', stopPolling);
|
||||
|
||||
startPolling();
|
||||
});
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
(function(global) {
|
||||
"use strict";
|
||||
|
||||
var queues = {};
|
||||
var morphdom = global.GOVUK.vendor.morphdom;
|
||||
var defaultInterval = 2000;
|
||||
var interval = 0;
|
||||
|
||||
var calculateBackoff = responseTime => parseInt(Math.max(
|
||||
(250 * Math.sqrt(responseTime)) - 1000,
|
||||
1000
|
||||
));
|
||||
|
||||
// Methods to ensure the DOM fragment is clean of classes added by JS before diffing
|
||||
// and that they are replaced afterwards.
|
||||
//
|
||||
// Added to allow the use of JS, in main.js, to apply styles which in future could be
|
||||
// achieved with the :has pseudo-class. If :has is available in our supported browsers,
|
||||
// this can be removed in favour of a CSS-only solution.
|
||||
var ClassesPersister = function ($contents) {
|
||||
this._$contents = $contents;
|
||||
this._classNames = [];
|
||||
this._classesTo$ElsMap = {};
|
||||
};
|
||||
ClassesPersister.prototype.addClassName = function (className) {
|
||||
if (this._classNames.indexOf(className) === -1) {
|
||||
this._classNames.push(className);
|
||||
}
|
||||
};
|
||||
ClassesPersister.prototype.remove = function () {
|
||||
// Store references to any elements with class names to persist
|
||||
this._classNames.forEach(className => {
|
||||
var $elsWithClassName = $('.' + className, this._$contents).removeClass(className);
|
||||
|
||||
if ($elsWithClassName.length > 0) {
|
||||
this._classesTo$ElsMap[className] = $elsWithClassName;
|
||||
}
|
||||
});
|
||||
};
|
||||
ClassesPersister.prototype.replace = function () {
|
||||
var replaceClasses = (idx, el) => {
|
||||
|
||||
// Avoid updating elements that are no longer present.
|
||||
// elements removed will still exist in memory but won't be attached to the DOM any more
|
||||
if (global.document.body.contains(el)) {
|
||||
$(el).addClass(className);
|
||||
}
|
||||
|
||||
};
|
||||
var className;
|
||||
|
||||
for (className in this._classesTo$ElsMap) {
|
||||
this._classesTo$ElsMap[className].each(replaceClasses);
|
||||
}
|
||||
|
||||
// remove references to elements
|
||||
this._classesTo$ElsMap = {};
|
||||
};
|
||||
|
||||
var getRenderer = ($contents, key, classesPersister) => response => {
|
||||
classesPersister.remove();
|
||||
morphdom(
|
||||
$contents.get(0),
|
||||
$(response[key]).get(0)
|
||||
);
|
||||
classesPersister.replace();
|
||||
};
|
||||
|
||||
var getQueue = resource => (
|
||||
queues[resource] = queues[resource] || []
|
||||
);
|
||||
|
||||
var flushQueue = function(queue, response) {
|
||||
while(queue.length) queue.shift()(response);
|
||||
};
|
||||
|
||||
var clearQueue = queue => (queue.length = 0);
|
||||
|
||||
var poll = function(renderer, resource, queue, form) {
|
||||
|
||||
let startTime = Date.now();
|
||||
|
||||
if (document.visibilityState !== "hidden" && queue.push(renderer) === 1) {
|
||||
$.ajax(
|
||||
resource,
|
||||
{
|
||||
'method': form ? 'post' : 'get',
|
||||
'data': form ? $('#' + form).serialize() : {}
|
||||
}
|
||||
).done(
|
||||
response => {
|
||||
flushQueue(queue, response);
|
||||
if (response.stop === 1) {
|
||||
poll = function(){};
|
||||
}
|
||||
interval = calculateBackoff(Date.now() - startTime);
|
||||
}
|
||||
).fail(
|
||||
() => poll = function(){}
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() => poll.apply(window, arguments), interval
|
||||
);
|
||||
};
|
||||
|
||||
global.GOVUK.Modules.UpdateContent = function() {
|
||||
|
||||
this.start = component => {
|
||||
var $component = $(component);
|
||||
var $contents = $component.children().eq(0);
|
||||
var key = $component.data('key');
|
||||
var resource = $component.data('resource');
|
||||
var form = $component.data('form');
|
||||
var classesPersister = new ClassesPersister($contents);
|
||||
|
||||
// Replace component with contents.
|
||||
// The renderer does this anyway when diffing against the first response
|
||||
$component.replaceWith($contents);
|
||||
|
||||
// Store any classes that should persist through updates
|
||||
//
|
||||
// Added to allow the use of JS, in main.js, to apply styles which in future could be
|
||||
// achieved with the :has pseudo-class. If :has is available in our supported browsers,
|
||||
// this can be removed in favour of a CSS-only solution.
|
||||
if ($contents.data('classesToPersist') !== undefined) {
|
||||
$contents.data('classesToPersist')
|
||||
.split(' ')
|
||||
.forEach(className => classesPersister.addClassName(className));
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() => poll(
|
||||
getRenderer($contents, key, classesPersister),
|
||||
resource,
|
||||
getQueue(resource),
|
||||
form
|
||||
),
|
||||
defaultInterval
|
||||
);
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
global.GOVUK.Modules.UpdateContent.calculateBackoff = calculateBackoff;
|
||||
|
||||
})(window);
|
||||
@@ -492,44 +492,12 @@ td.table-empty-message {
|
||||
.job-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
td.file-name {
|
||||
width: 25%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
td.jobid {
|
||||
width: 5%;
|
||||
}
|
||||
td.template {
|
||||
width: 25%;
|
||||
}
|
||||
td.time-sent {
|
||||
width: 15%;
|
||||
}
|
||||
td.sender {
|
||||
width: 20%;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
td.count-of-recipients {
|
||||
width: 5%;
|
||||
}
|
||||
td.report {
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
}
|
||||
td.delivered {
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
}
|
||||
td.failed {
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
}
|
||||
td.report img {
|
||||
padding-top: 5px;
|
||||
}
|
||||
th {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
td {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -1092,3 +1060,14 @@ nav.nav {
|
||||
.selection-content .usa-form-group--nested li.usa-checkbox {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// Radio button and checkbox legends
|
||||
.usa-radio legend.usa-legend,
|
||||
.usa-checkbox legend.usa-legend,
|
||||
.api-key-radios legend.usa-legend,
|
||||
.login-auth-radios legend.usa-legend,
|
||||
.auth-type-radios legend.usa-legend,
|
||||
.notification-type-radios legend.usa-legend,
|
||||
.organizations-radios legend.usa-legend {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -16,12 +16,11 @@ class Config(object):
|
||||
API_PUBLIC_WS_URL = getenv("API_PUBLIC_WS_URL", "localhost")
|
||||
|
||||
ADMIN_BASE_URL = getenv("ADMIN_BASE_URL", "http://localhost:6012")
|
||||
HEADER_COLOUR = (
|
||||
"#81878b" # mix of dark-grey and mid-grey
|
||||
)
|
||||
HEADER_COLOUR = "#81878b" # mix of dark-grey and mid-grey
|
||||
LOGO_CDN_DOMAIN = (
|
||||
"static-logos.notifications.service.gov.uk" # TODO use our own CDN
|
||||
)
|
||||
|
||||
ASSETS_DEBUG = False
|
||||
|
||||
# Credentials
|
||||
|
||||
@@ -11,14 +11,13 @@ import ago
|
||||
import dateutil
|
||||
import humanize
|
||||
import markdown
|
||||
import pytz
|
||||
from bs4 import BeautifulSoup
|
||||
from flask import render_template_string, url_for
|
||||
from flask.helpers import get_root_path
|
||||
from markupsafe import Markup
|
||||
|
||||
from app.enums import AuthType, NotificationStatus
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone, get_user_preferred_timezone_obj
|
||||
from app.utils.time import parse_naive_dt
|
||||
from notifications_utils.field import Field
|
||||
from notifications_utils.formatters import make_quotes_smart
|
||||
@@ -119,7 +118,7 @@ def format_datetime_table(date):
|
||||
def format_time_12h(date):
|
||||
date = parse_naive_dt(date)
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%I:%M %p")
|
||||
)
|
||||
@@ -140,7 +139,7 @@ def format_datetime_numeric(date):
|
||||
def format_date_numeric(date):
|
||||
date = parse_naive_dt(date)
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%m-%d-%Y")
|
||||
)
|
||||
@@ -149,14 +148,14 @@ def format_date_numeric(date):
|
||||
def format_time_24h(date):
|
||||
date = parse_naive_dt(date)
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%H:%M")
|
||||
|
||||
|
||||
def get_human_day(time, date_prefix=""):
|
||||
# Add 1 minute to transform 00:00 into ‘midnight today’ instead of ‘midnight tomorrow’
|
||||
time = parse_naive_dt(time)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
time = time.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
|
||||
date = (time - timedelta(minutes=1)).date()
|
||||
now = datetime.now(preferred_tz)
|
||||
@@ -181,7 +180,7 @@ def get_human_day(time, date_prefix=""):
|
||||
|
||||
def format_date(date):
|
||||
date = parse_naive_dt(date)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc)
|
||||
.astimezone(preferred_tz)
|
||||
@@ -196,7 +195,7 @@ def format_date_normal(date):
|
||||
|
||||
def format_date_short(date):
|
||||
date = parse_naive_dt(date)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return _format_datetime_short(
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
|
||||
)
|
||||
@@ -216,7 +215,7 @@ def format_datetime_human(date, date_prefix=""):
|
||||
|
||||
def format_day_of_week(date):
|
||||
date = parse_naive_dt(date)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%A")
|
||||
|
||||
|
||||
@@ -422,7 +421,7 @@ def redact_mobile_number(mobile_number, spacing=""):
|
||||
return "".join(mobile_number_list)
|
||||
|
||||
|
||||
def get_time_left(created_at, service_data_retention_days=7):
|
||||
def get_time_left(created_at, service_data_retention_days=8):
|
||||
return ago.human(
|
||||
(datetime.now(timezone.utc))
|
||||
- (
|
||||
|
||||
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
|
||||
from itertools import chain
|
||||
from numbers import Number
|
||||
|
||||
import pytz
|
||||
from flask import render_template, request
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm as Form
|
||||
@@ -62,7 +61,7 @@ from app.main.validators import (
|
||||
)
|
||||
from app.models.organization import Organization
|
||||
from app.utils import merge_jsonlike
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone, get_user_preferred_timezone_obj
|
||||
from app.utils.user_permissions import all_ui_permissions, permission_options
|
||||
from notifications_utils.formatters import strip_all_whitespace
|
||||
from notifications_utils.insensitive_dict import InsensitiveDict
|
||||
@@ -70,7 +69,7 @@ from notifications_utils.recipients import InvalidPhoneError, validate_phone_num
|
||||
|
||||
|
||||
def get_time_value_and_label(future_time):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
future_time.astimezone(preferred_tz).isoformat(),
|
||||
"{} at {} {}".format(
|
||||
@@ -90,7 +89,7 @@ def get_human_time(time):
|
||||
def get_human_day(time, prefix_today_with="T"):
|
||||
# Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
time = (time - timedelta(hours=1)).strftime("%A")
|
||||
if time == datetime.now(preferred_tz).strftime("%A"):
|
||||
return "{}oday".format(prefix_today_with)
|
||||
@@ -101,12 +100,12 @@ def get_human_day(time, prefix_today_with="T"):
|
||||
|
||||
def get_furthest_possible_scheduled_time():
|
||||
# We want local time to find date boundaries
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (datetime.now(preferred_tz) + timedelta(days=4)).replace(hour=0)
|
||||
|
||||
|
||||
def get_next_hours_until(until):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
now = datetime.now(preferred_tz)
|
||||
hours = int((until - now).total_seconds() / (60 * 60))
|
||||
return [
|
||||
@@ -116,7 +115,7 @@ def get_next_hours_until(until):
|
||||
|
||||
|
||||
def get_next_days_until(until):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
now = datetime.now(preferred_tz)
|
||||
days = int((until - now).total_seconds() / (60 * 60 * 24))
|
||||
return [
|
||||
@@ -1727,10 +1726,18 @@ class TemplateFolderForm(StripWhitespaceForm):
|
||||
def __init__(self, all_service_users=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if all_service_users is not None:
|
||||
self.users_with_permission.all_service_users = all_service_users
|
||||
self.users_with_permission.choices = [
|
||||
(item.id, item.name) for item in all_service_users
|
||||
regular_users = [
|
||||
user for user in all_service_users if not user.platform_admin
|
||||
]
|
||||
platform_admins = [
|
||||
user for user in all_service_users if user.platform_admin
|
||||
]
|
||||
|
||||
self.users_with_permission.all_service_users = regular_users
|
||||
self.users_with_permission.choices = [
|
||||
(item.id, item.name) for item in regular_users
|
||||
]
|
||||
self.platform_admins = platform_admins
|
||||
|
||||
users_with_permission = USWDSCollapsibleCheckboxesField(
|
||||
"Team members who can see this folder", field_label="team member"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import gevent
|
||||
from flask import abort, render_template, request, url_for
|
||||
|
||||
from app import current_service, job_api_client
|
||||
@@ -13,42 +14,128 @@ from app.utils.pagination import (
|
||||
from app.utils.user import user_has_permissions
|
||||
|
||||
|
||||
def get_download_availability(service_id):
|
||||
"""
|
||||
Check if there are jobs available for each download time period.
|
||||
"""
|
||||
jobs_1_day = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=1)
|
||||
jobs_3_days = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=3)
|
||||
jobs_5_days = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=5)
|
||||
jobs_7_days = job_api_client.get_immediate_jobs(service_id)
|
||||
def get_report_info(service_id, report_name, s3_config):
|
||||
try:
|
||||
from app.s3_client import check_s3_file_exists, get_s3_object
|
||||
from app.s3_client.s3_csv_client import NEW_FILE_LOCATION_STRUCTURE
|
||||
|
||||
has_1_day_data = len(generate_job_dict(jobs_1_day)) > 0
|
||||
has_3_day_data = len(generate_job_dict(jobs_3_days)) > 0
|
||||
has_5_day_data = len(generate_job_dict(jobs_5_days)) > 0
|
||||
has_7_day_data = len(jobs_7_days) > 0
|
||||
key = NEW_FILE_LOCATION_STRUCTURE.format(service_id, report_name)
|
||||
obj = get_s3_object(
|
||||
s3_config["bucket"],
|
||||
key,
|
||||
s3_config["access_key_id"],
|
||||
s3_config["secret_access_key"],
|
||||
s3_config["region"],
|
||||
)
|
||||
|
||||
exists = check_s3_file_exists(obj)
|
||||
|
||||
if exists:
|
||||
# check_s3_file_exists already called obj.load(), so metadata should be populated
|
||||
size_bytes = obj.content_length
|
||||
|
||||
# Only show as available if file has any content (not empty)
|
||||
if size_bytes > 0:
|
||||
if size_bytes < 1024:
|
||||
size_str = f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
size_str = f"{size_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
return {"available": True, "size": size_str}
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
|
||||
return {"available": False, "size": None}
|
||||
|
||||
|
||||
def get_download_availability(service_id):
|
||||
from flask import current_app
|
||||
|
||||
# Get S3 config before spawning greenlets
|
||||
s3_config = {
|
||||
"bucket": current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
|
||||
"access_key_id": current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"],
|
||||
"secret_access_key": current_app.config["CSV_UPLOAD_BUCKET"][
|
||||
"secret_access_key"
|
||||
],
|
||||
"region": current_app.config["CSV_UPLOAD_BUCKET"]["region"],
|
||||
}
|
||||
|
||||
report_names = ["1-day-report", "3-day-report", "5-day-report", "7-day-report"]
|
||||
|
||||
greenlets = [
|
||||
gevent.spawn(get_report_info, service_id, name, s3_config)
|
||||
for name in report_names
|
||||
]
|
||||
gevent.joinall(greenlets)
|
||||
results = [g.value for g in greenlets]
|
||||
|
||||
return {
|
||||
"has_1_day_data": has_1_day_data,
|
||||
"has_3_day_data": has_3_day_data,
|
||||
"has_5_day_data": has_5_day_data,
|
||||
"has_7_day_data": has_7_day_data,
|
||||
"has_any_download_data": has_1_day_data
|
||||
or has_3_day_data
|
||||
or has_5_day_data
|
||||
or has_7_day_data,
|
||||
"report_1_day": (
|
||||
results[0] if results[0] else {"available": False, "size": None}
|
||||
),
|
||||
"report_3_day": (
|
||||
results[1] if results[1] else {"available": False, "size": None}
|
||||
),
|
||||
"report_5_day": (
|
||||
results[2] if results[2] else {"available": False, "size": None}
|
||||
),
|
||||
"report_7_day": (
|
||||
results[3] if results[3] else {"available": False, "size": None}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_download_links(message_type):
|
||||
time_periods = ["one_day", "three_day", "five_day", "seven_day"]
|
||||
links = {}
|
||||
|
||||
for period in time_periods:
|
||||
links[f"download_link_{period}"] = url_for(
|
||||
".download_notifications_csv",
|
||||
service_id=current_service.id,
|
||||
message_type=message_type,
|
||||
status=request.args.get("status"),
|
||||
number_of_days=period,
|
||||
)
|
||||
return links
|
||||
|
||||
|
||||
def get_filtered_jobs(service_id, page):
|
||||
filter_type = request.args.get("filter")
|
||||
|
||||
limit_days = None
|
||||
if filter_type == "24hours":
|
||||
limit_days = 1
|
||||
elif filter_type == "3days":
|
||||
limit_days = 3
|
||||
elif filter_type == "7days":
|
||||
limit_days = 7
|
||||
|
||||
if limit_days:
|
||||
return job_api_client.get_page_of_jobs(
|
||||
service_id, page=page, limit_days=limit_days, use_processing_time=True
|
||||
)
|
||||
else:
|
||||
return job_api_client.get_page_of_jobs(service_id, page=page)
|
||||
|
||||
|
||||
@main.route("/activity/services/<uuid:service_id>")
|
||||
@user_has_permissions(ServicePermission.VIEW_ACTIVITY)
|
||||
def all_jobs_activity(service_id):
|
||||
service_data_retention_days = 7
|
||||
service_data_retention_days = 8
|
||||
page = get_page_from_request()
|
||||
jobs = job_api_client.get_page_of_jobs(service_id, page=page)
|
||||
|
||||
jobs = get_filtered_jobs(service_id, page)
|
||||
|
||||
all_jobs_dict = generate_job_dict(jobs)
|
||||
prev_page, next_page, pagination = handle_pagination(jobs, service_id, page)
|
||||
message_type = ("sms",)
|
||||
download_availability = get_download_availability(service_id)
|
||||
download_links = get_download_links(message_type)
|
||||
|
||||
return render_template(
|
||||
"views/activity/all-activity.html",
|
||||
all_jobs_dict=all_jobs_dict,
|
||||
@@ -56,51 +143,32 @@ def all_jobs_activity(service_id):
|
||||
next_page=next_page,
|
||||
prev_page=prev_page,
|
||||
pagination=pagination,
|
||||
total_jobs=jobs.get("total", 0),
|
||||
**download_availability,
|
||||
download_link_one_day=url_for(
|
||||
".download_notifications_csv",
|
||||
service_id=current_service.id,
|
||||
message_type=message_type,
|
||||
status=request.args.get("status"),
|
||||
number_of_days="one_day",
|
||||
),
|
||||
download_link_three_day=url_for(
|
||||
".download_notifications_csv",
|
||||
service_id=current_service.id,
|
||||
message_type=message_type,
|
||||
status=request.args.get("status"),
|
||||
number_of_days="three_day",
|
||||
),
|
||||
download_link_five_day=url_for(
|
||||
".download_notifications_csv",
|
||||
service_id=current_service.id,
|
||||
message_type=message_type,
|
||||
status=request.args.get("status"),
|
||||
number_of_days="five_day",
|
||||
),
|
||||
download_link_seven_day=url_for(
|
||||
".download_notifications_csv",
|
||||
service_id=current_service.id,
|
||||
message_type=message_type,
|
||||
status=request.args.get("status"),
|
||||
number_of_days="seven_day",
|
||||
),
|
||||
**download_links,
|
||||
)
|
||||
|
||||
|
||||
def handle_pagination(jobs, service_id, page):
|
||||
if page is None:
|
||||
abort(404, "Invalid page argument ({}).".format(request.args.get("page")))
|
||||
|
||||
url_args = {}
|
||||
if request.args.get("filter"):
|
||||
url_args["filter"] = request.args.get("filter")
|
||||
|
||||
prev_page = (
|
||||
generate_previous_dict("main.all_jobs_activity", service_id, page)
|
||||
generate_previous_dict("main.all_jobs_activity", service_id, page, url_args)
|
||||
if page > 1
|
||||
else None
|
||||
)
|
||||
total_items = jobs.get("total", 0)
|
||||
page_size = jobs.get("page_size", 50)
|
||||
total_pages = (total_items + page_size - 1) // page_size
|
||||
has_next_link = jobs.get("links", {}).get("next") is not None
|
||||
next_page = (
|
||||
generate_next_dict("main.all_jobs_activity", service_id, page)
|
||||
if has_next_link and total_items > 50
|
||||
generate_next_dict("main.all_jobs_activity", service_id, page, url_args)
|
||||
if has_next_link and total_items > 50 and page < total_pages
|
||||
else None
|
||||
)
|
||||
pagination = generate_pagination_pages(
|
||||
|
||||
@@ -41,7 +41,7 @@ def service_dashboard(service_id):
|
||||
return redirect(url_for("main.choose_template", service_id=service_id))
|
||||
|
||||
job_response = job_api_client.get_jobs(service_id)["data"]
|
||||
service_data_retention_days = 7
|
||||
service_data_retention_days = 8
|
||||
|
||||
active_jobs = [
|
||||
job for job in job_response if job["job_status"] != JobStatus.CANCELLED
|
||||
@@ -55,7 +55,7 @@ def service_dashboard(service_id):
|
||||
messages_remaining = total_messages.get("messages_remaining", 0)
|
||||
messages_sent = total_messages.get("messages_sent", 0)
|
||||
all_statistics = template_statistics_client.get_template_statistics_for_service(
|
||||
service_id, limit_days=7
|
||||
service_id, limit_days=8
|
||||
)
|
||||
template_statistics = aggregate_template_usage(all_statistics)
|
||||
return render_template(
|
||||
@@ -361,7 +361,7 @@ def get_stats_date_range():
|
||||
current_financial_year = get_current_financial_year()
|
||||
current_month = get_current_month_for_financial_year(current_financial_year)
|
||||
start_date = datetime.now().strftime("%Y-%m-%d")
|
||||
days = 7
|
||||
days = 8
|
||||
return {
|
||||
"current_financial_year": current_financial_year,
|
||||
"current_month": current_month,
|
||||
|
||||
@@ -254,10 +254,13 @@ def get_notifications(service_id, message_type, status_override=None): # noqa
|
||||
next_page = None
|
||||
|
||||
total_items = notifications.get("total", 0)
|
||||
page_size = notifications.get("page_size", 50)
|
||||
total_pages = (total_items + page_size - 1) // page_size
|
||||
if (
|
||||
"links" in notifications
|
||||
and notifications["links"].get("next", None)
|
||||
and total_items > 50
|
||||
and page < total_pages
|
||||
):
|
||||
next_page = generate_next_dict(
|
||||
"main.view_notifications", service_id, page, url_args
|
||||
|
||||
@@ -73,7 +73,9 @@ def invite_user(service_id, user_id=None):
|
||||
else:
|
||||
user_to_invite = None
|
||||
|
||||
service_has_email_auth = current_service.has_permission(ServicePermission.EMAIL_AUTH)
|
||||
service_has_email_auth = current_service.has_permission(
|
||||
ServicePermission.EMAIL_AUTH
|
||||
)
|
||||
if not service_has_email_auth:
|
||||
form.login_authentication.data = "sms_auth"
|
||||
|
||||
@@ -116,7 +118,9 @@ def invite_user(service_id, user_id=None):
|
||||
@main.route("/services/<uuid:service_id>/users/<uuid:user_id>", methods=["GET", "POST"])
|
||||
@user_has_permissions(ServicePermission.MANAGE_SERVICE)
|
||||
def edit_user_permissions(service_id, user_id):
|
||||
service_has_email_auth = current_service.has_permission(ServicePermission.EMAIL_AUTH)
|
||||
service_has_email_auth = current_service.has_permission(
|
||||
ServicePermission.EMAIL_AUTH
|
||||
)
|
||||
user = current_service.get_team_member(user_id)
|
||||
|
||||
mobile_number = None
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
current_app,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
stream_with_context,
|
||||
@@ -14,7 +17,9 @@ from flask import (
|
||||
from app import current_service, job_api_client, notification_api_client
|
||||
from app.enums import ServicePermission
|
||||
from app.main import main
|
||||
from app.main.views.user_profile import set_timezone
|
||||
from app.notify_client.api_key_api_client import KEY_TYPE_TEST
|
||||
from app.s3_client.s3_csv_client import s3download
|
||||
from app.utils import (
|
||||
DELIVERED_STATUSES,
|
||||
FAILURE_STATUSES,
|
||||
@@ -23,8 +28,10 @@ from app.utils import (
|
||||
set_status_filters,
|
||||
)
|
||||
from app.utils.csv import generate_notifications_csv, get_user_preferred_timezone
|
||||
from app.utils.s3_csv import convert_s3_csv_timestamps
|
||||
from app.utils.templates import get_template
|
||||
from app.utils.user import user_has_permissions
|
||||
from notifications_utils.s3 import S3ObjectNotFound
|
||||
|
||||
|
||||
@main.route("/services/<uuid:service_id>/notification/<uuid:notification_id>")
|
||||
@@ -94,7 +101,9 @@ def view_notification(service_id, notification_id, error_message=None):
|
||||
updated_at=notification["sent_at"],
|
||||
help=get_help_argument(),
|
||||
notification_id=notification["id"],
|
||||
can_receive_inbound=(current_service.has_permission(ServicePermission.INBOUND_SMS)),
|
||||
can_receive_inbound=(
|
||||
current_service.has_permission(ServicePermission.INBOUND_SMS)
|
||||
),
|
||||
sent_with_test_key=(notification.get("key_type") == KEY_TYPE_TEST),
|
||||
back_link=back_link,
|
||||
)
|
||||
@@ -133,23 +142,73 @@ def get_all_personalisation_from_notification(notification):
|
||||
return notification["personalisation"]
|
||||
|
||||
|
||||
PERIOD_TO_S3_FILENAME = {
|
||||
"one_day": "1-day-report",
|
||||
"three_day": "3-day-report",
|
||||
"five_day": "5-day-report",
|
||||
"seven_day": "7-day-report",
|
||||
}
|
||||
|
||||
|
||||
@main.route("/services/<uuid:service_id>/download-notifications.csv")
|
||||
@user_has_permissions(ServicePermission.VIEW_ACTIVITY)
|
||||
def download_notifications_csv(service_id):
|
||||
set_timezone()
|
||||
filter_args = parse_filter_args(request.args)
|
||||
filter_args["status"] = set_status_filters(filter_args)
|
||||
number_of_days = request.args["number_of_days"]
|
||||
service_data_retention_days = current_service.get_days_of_retention(
|
||||
filter_args.get("message_type")[0], number_of_days
|
||||
)
|
||||
file_time = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
file_time = f"{file_time} {get_user_preferred_timezone()}"
|
||||
user_tz_name = get_user_preferred_timezone()
|
||||
user_tz = ZoneInfo(user_tz_name)
|
||||
file_time = datetime.now(user_tz).strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
file_time = f"{file_time} {user_tz_name}"
|
||||
|
||||
job_id = request.args.get("job_id")
|
||||
if not job_id and number_of_days in PERIOD_TO_S3_FILENAME:
|
||||
try:
|
||||
s3_report_id = PERIOD_TO_S3_FILENAME[number_of_days]
|
||||
current_app.logger.info(
|
||||
f"User is attempting to download {s3_report_id} for service {service_id}"
|
||||
)
|
||||
s3_file_content = s3download(service_id, s3_report_id)
|
||||
return Response(
|
||||
stream_with_context(
|
||||
convert_s3_csv_timestamps(
|
||||
s3_file_content, user_timezone=user_tz_name
|
||||
)
|
||||
),
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": 'inline; filename="{} - {} - {} report.csv"'.format(
|
||||
file_time,
|
||||
filter_args["message_type"][0],
|
||||
current_service.name,
|
||||
)
|
||||
},
|
||||
)
|
||||
except S3ObjectNotFound:
|
||||
current_app.logger.warning(
|
||||
f"File {s3_report_id} was expected but not found for service {service_id}. "
|
||||
"It may have been deleted after page load."
|
||||
)
|
||||
flash(
|
||||
"The report is no longer available. Please refresh the page.", "default"
|
||||
)
|
||||
return redirect(
|
||||
url_for(
|
||||
"main.view_notifications",
|
||||
service_id=service_id,
|
||||
message_type=filter_args["message_type"][0],
|
||||
status="sending,delivered,failed",
|
||||
)
|
||||
)
|
||||
return Response(
|
||||
stream_with_context(
|
||||
generate_notifications_csv(
|
||||
service_id=service_id,
|
||||
job_id=None,
|
||||
job_id=job_id,
|
||||
status=filter_args.get("status"),
|
||||
page=request.args.get("page", 1),
|
||||
page_size=10000,
|
||||
|
||||
@@ -3,17 +3,16 @@ from itertools import groupby
|
||||
from operator import itemgetter
|
||||
from statistics import mean
|
||||
|
||||
import pytz
|
||||
from flask import render_template
|
||||
|
||||
from app import performance_dashboard_api_client, status_api_client
|
||||
from app.main import main
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone_obj
|
||||
|
||||
|
||||
@main.route("/performance")
|
||||
def performance():
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
stats = performance_dashboard_api_client.get_performance_dashboard_stats(
|
||||
start_date=(datetime.now(preferred_tz) - timedelta(days=7)).date(),
|
||||
end_date=datetime.now(preferred_tz).date(),
|
||||
|
||||
@@ -154,17 +154,23 @@ def set_up_your_profile():
|
||||
code = request.args.get("code")
|
||||
state = request.args.get("state")
|
||||
|
||||
state_key = f"login-state-{unquote(state)}"
|
||||
stored_state = unquote(redis_client.get(state_key).decode("utf8"))
|
||||
if state != stored_state:
|
||||
flash("Internal error: cannot recognize stored state")
|
||||
abort(403, "Internal error: cannot recognize stored state #invites")
|
||||
|
||||
login_gov_error = request.args.get("error")
|
||||
if login_gov_error:
|
||||
flash(f"Login.gov error: {login_gov_error}")
|
||||
abort(403, f"login_gov_error {login_gov_error} #invites")
|
||||
|
||||
if not state:
|
||||
flash("Login.gov state not detected")
|
||||
abort(403, "Login.gov state not detected #invites")
|
||||
|
||||
state_key = f"login-state-{unquote(state)}"
|
||||
debug_msg(f"Register tries to fetch state_key {state_key}")
|
||||
stored_state = unquote(redis_client.get(state_key).decode("utf8"))
|
||||
|
||||
if state != stored_state:
|
||||
flash("Internal error: cannot recognize stored state")
|
||||
abort(403, "Internal error: cannot recognize stored state #invites")
|
||||
|
||||
user_email = redis_client.get(f"user_email-{state}")
|
||||
user_uuid = redis_client.get(f"user_uuid-{state}")
|
||||
|
||||
@@ -181,21 +187,33 @@ def set_up_your_profile():
|
||||
f"#invites: Got the user_email and user_uuid {user_uuid} from login.gov"
|
||||
)
|
||||
invite_data = redis_client.get(f"invitedata-{state}")
|
||||
# TODO fails here.
|
||||
invite_data = json.loads(invite_data)
|
||||
invited_user_id = invite_data["invited_user_id"]
|
||||
invited_user_email_address = get_invited_user_email_address(invited_user_id)
|
||||
|
||||
is_org_invite, invited_user_id, invited_user_email_address = (
|
||||
process_invited_user(invite_data)
|
||||
)
|
||||
|
||||
current_app.logger.info(
|
||||
f"#invites: does user email match expected? {user_email == invited_user_email_address}"
|
||||
)
|
||||
check_invited_user_email_address_matches_expected(
|
||||
user_email, invited_user_email_address
|
||||
)
|
||||
if is_org_invite:
|
||||
invited_org_user_accept_invite(invited_user_id)
|
||||
current_app.logger.info(
|
||||
f"#invites: accepted org invite user with invited_user_id \
|
||||
{invited_user_id}"
|
||||
)
|
||||
|
||||
else:
|
||||
invited_user_accept_invite(invited_user_id)
|
||||
current_app.logger.info(
|
||||
f"#invites: accepted invite user with invited_user_id \
|
||||
{invited_user_id}"
|
||||
)
|
||||
|
||||
invited_user_accept_invite(invited_user_id)
|
||||
current_app.logger.info(
|
||||
f"#invites: accepted invite user with invited_user_id \
|
||||
{invited_user_id} to service {invite_data['service_id']}"
|
||||
)
|
||||
# We need to avoid taking a second trip through the login.gov code because we cannot pull the
|
||||
# access token twice. So once we retrieve these values, let's park them in redis for 15 minutes
|
||||
put_invite_data_in_redis(
|
||||
@@ -235,22 +253,31 @@ def set_up_your_profile():
|
||||
current_app.logger.info("#invites: going to activate user")
|
||||
activate_user(user["id"])
|
||||
current_app.logger.info(f"#invites: activated user with user.id {user['id']}")
|
||||
usr = User.from_id(user["id"])
|
||||
|
||||
usr.add_to_service(
|
||||
invite_data["service_id"],
|
||||
invite_data["permissions"],
|
||||
invite_data["folder_permissions"],
|
||||
invite_data["from_user_id"],
|
||||
)
|
||||
if invite_data.get("service_id"):
|
||||
usr = User.from_id(user["id"])
|
||||
|
||||
# notify-admin-1766
|
||||
# redirect new users to templates area of new service instead of dashboard
|
||||
service_id = invite_data["service_id"]
|
||||
url = url_for(".service_dashboard", service_id=service_id)
|
||||
url = f"{url}/templates"
|
||||
current_app.logger.info(f"#invites redirecting to {url}")
|
||||
return redirect(url)
|
||||
usr.add_to_service(
|
||||
invite_data["service_id"],
|
||||
invite_data["permissions"],
|
||||
invite_data["folder_permissions"],
|
||||
invite_data["from_user_id"],
|
||||
)
|
||||
|
||||
# notify-admin-1766
|
||||
# redirect new users to templates area of new service instead of dashboard
|
||||
service_id = invite_data["service_id"]
|
||||
url = url_for(".service_dashboard", service_id=service_id)
|
||||
url = f"{url}/templates"
|
||||
current_app.logger.info(f"#invites redirecting to {url}")
|
||||
return redirect(url)
|
||||
else:
|
||||
usr = User.from_id(user["id"])
|
||||
org_id = invite_data["organization"]
|
||||
usr.add_to_organization(org_id)
|
||||
url = url_for(".organization_dashboard", org_id=org_id)
|
||||
current_app.logger.info(f"#invites redirecting to {url}")
|
||||
return redirect(url)
|
||||
|
||||
# we take two trips through this method, but should only hit this
|
||||
# line on the first trip. On the second trip, we should get redirected
|
||||
@@ -265,25 +292,67 @@ def get_invited_user_email_address(invited_user_id):
|
||||
return invited_user.email_address
|
||||
|
||||
|
||||
def get_invited_org_user_email_address(invited_user_id):
|
||||
# InvitedUser is an unhashable type and hard to mock in tests
|
||||
# so this convenience method is a workaround for that
|
||||
invited_user = InvitedOrgUser.by_id(invited_user_id)
|
||||
return invited_user.email_address
|
||||
|
||||
|
||||
def invited_user_accept_invite(invited_user_id):
|
||||
invited_user = InvitedUser.by_id(invited_user_id)
|
||||
|
||||
if invited_user.status == InvitedUserStatus.EXPIRED:
|
||||
current_app.logger.error("User invitation has expired")
|
||||
current_app.logger.error("Service invitation has expired")
|
||||
flash(
|
||||
"Your invitation has expired; please contact the person who invited you for additional help."
|
||||
"Your service invitation has expired; please contact the person who invited you for additional help."
|
||||
)
|
||||
abort(401, "Your invitation has expired #invites")
|
||||
abort(401, "Your service invitation has expired #invites")
|
||||
|
||||
if invited_user.status == InvitedUserStatus.CANCELLED:
|
||||
current_app.logger.error("User invitation has been cancelled")
|
||||
current_app.logger.error("Service invitation has been cancelled")
|
||||
flash(
|
||||
"Your invitation is no longer valid; please contact the person who invited you for additional help."
|
||||
"Your service invitation is no longer valid; please contact the person who invited you for additional help."
|
||||
)
|
||||
abort(401, "Your invitation was canceled #invites")
|
||||
abort(401, "Your service invitation was canceled #invites")
|
||||
|
||||
invited_user.accept_invite()
|
||||
|
||||
|
||||
def invited_org_user_accept_invite(invited_user_id):
|
||||
invited_user = InvitedOrgUser.by_id(invited_user_id)
|
||||
|
||||
if invited_user.status == InvitedUserStatus.EXPIRED:
|
||||
current_app.logger.error("Organization invitation has expired")
|
||||
flash(
|
||||
"Your organization invitation has expired; please contact the person who invited you for additional help."
|
||||
)
|
||||
abort(401, "Your organization invitation has expired #invites")
|
||||
|
||||
if invited_user.status == InvitedUserStatus.CANCELLED:
|
||||
current_app.logger.error("Organization invitation has been cancelled")
|
||||
flash(
|
||||
"Your organization invitation is no longer valid; \
|
||||
please contact the person who invited you for additional help."
|
||||
)
|
||||
abort(401, "Your organization invitation was canceled #invites")
|
||||
|
||||
invited_user.accept_invite()
|
||||
|
||||
|
||||
def debug_msg(msg):
|
||||
current_app.logger.debug(hilite(msg))
|
||||
|
||||
|
||||
def process_invited_user(invite_data):
|
||||
is_org_invite = False
|
||||
if invite_data.get("invited_user_id"):
|
||||
invited_user_id = invite_data["invited_user_id"]
|
||||
|
||||
invited_user_email_address = get_invited_user_email_address(invited_user_id)
|
||||
else:
|
||||
invited_user_id = invite_data["id"]
|
||||
is_org_invite = True
|
||||
invited_user_email_address = get_invited_org_user_email_address(invited_user_id)
|
||||
|
||||
return is_org_invite, invited_user_id, invited_user_email_address
|
||||
|
||||
@@ -560,7 +560,9 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs):
|
||||
max_errors_shown=50,
|
||||
guestlist=allow_list,
|
||||
remaining_messages=remaining_messages,
|
||||
allow_international_sms=current_service.has_permission(ServicePermission.INTERNATIONAL_SMS),
|
||||
allow_international_sms=current_service.has_permission(
|
||||
ServicePermission.INTERNATIONAL_SMS
|
||||
),
|
||||
)
|
||||
|
||||
if request.args.get("from_test"):
|
||||
|
||||
@@ -146,6 +146,9 @@ def _do_login_dot_gov(): # $ pragma: no cover
|
||||
# return verify_email(user, redirect_url)
|
||||
|
||||
usr = User.from_email_address(user["email_address"])
|
||||
|
||||
# New users on the invite path need activation
|
||||
# if not usr.is_active:
|
||||
current_app.logger.info(f"activating user {usr.id} #notify-admin-1505")
|
||||
activate_user(usr.id)
|
||||
except BaseException as be: # noqa B036
|
||||
|
||||
@@ -27,7 +27,7 @@ def _sign_out_at_login_dot_gov():
|
||||
def sign_out():
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.deactivate()
|
||||
# current_user.deactivate()
|
||||
session.clear()
|
||||
current_user.sign_out()
|
||||
|
||||
|
||||
@@ -180,7 +180,10 @@ def process_folder_management_form(form, current_folder_id):
|
||||
|
||||
if form.is_add_folder_op:
|
||||
new_folder_id = template_folder_api_client.create_template_folder(
|
||||
current_service.id, name=form.get_folder_name(), parent_id=current_folder_id
|
||||
current_service.id,
|
||||
name=form.get_folder_name(),
|
||||
parent_id=current_folder_id,
|
||||
created_by_id=str(current_user.id),
|
||||
)
|
||||
|
||||
if form.is_move_op:
|
||||
|
||||
@@ -202,7 +202,6 @@ class User(JSONModel, UserMixin):
|
||||
@property
|
||||
def is_gov_user(self):
|
||||
is_gov = is_gov_user(self.email_address)
|
||||
# current_app.logger.info(f"User {self.id} is_gov_user: {is_gov}")
|
||||
return is_gov
|
||||
|
||||
@property
|
||||
@@ -211,9 +210,6 @@ class User(JSONModel, UserMixin):
|
||||
|
||||
@property
|
||||
def platform_admin(self):
|
||||
# current_app.logger.warning(
|
||||
# f"Checking User {self.id} for platform admin: {self._platform_admin}"
|
||||
# )
|
||||
return self._platform_admin and not session.get(
|
||||
"disable_platform_admin_view", False
|
||||
)
|
||||
@@ -243,14 +239,10 @@ class User(JSONModel, UserMixin):
|
||||
# we shouldn't have any pages that require permissions, but don't specify a service or organization.
|
||||
# use @user_is_platform_admin for platform admin only pages
|
||||
# raise NotImplementedError
|
||||
# current_app.logger.warning(f"VIEW ARGS ARE {request.view_args}")
|
||||
pass
|
||||
|
||||
# platform admins should be able to do most things (except eg send messages, or create api keys)
|
||||
if self.platform_admin and not restrict_admin_usage:
|
||||
current_app.logger.debug(
|
||||
"has_permissions is true because user is platform_admin"
|
||||
)
|
||||
return True
|
||||
|
||||
if org_id:
|
||||
@@ -564,17 +556,11 @@ class InvitedUser(JSONModel):
|
||||
return cls.by_id(invited_user_id) if invited_user_id else None
|
||||
|
||||
def has_permissions(self, *permissions):
|
||||
# current_app.logger.warning(
|
||||
# f"Checking invited user {self.id} for permissions: {permissions}"
|
||||
# )
|
||||
if self.status == InvitedUserStatus.CANCELLED:
|
||||
return False
|
||||
return set(self.permissions) > set(permissions)
|
||||
|
||||
def has_permission_for_service(self, service_id, permission):
|
||||
# current_app.logger.warn(
|
||||
# f"Checking invited user {self.id} for permission: {permission} on service {service_id}"
|
||||
# )
|
||||
if self.status == InvitedUserStatus.CANCELLED:
|
||||
return False
|
||||
return self.service == service_id and permission in self.permissions
|
||||
|
||||
@@ -32,12 +32,22 @@ class JobApiClient(NotifyAdminAPIClient):
|
||||
|
||||
return job
|
||||
|
||||
def get_jobs(self, service_id, *, limit_days=None, statuses=None, page=1):
|
||||
def get_jobs(
|
||||
self,
|
||||
service_id,
|
||||
*,
|
||||
limit_days=None,
|
||||
statuses=None,
|
||||
page=1,
|
||||
use_processing_time=False,
|
||||
):
|
||||
params = {"page": page}
|
||||
if limit_days is not None:
|
||||
params["limit_days"] = limit_days
|
||||
if statuses is not None:
|
||||
params["statuses"] = ",".join(statuses)
|
||||
if use_processing_time:
|
||||
params["use_processing_time"] = "true"
|
||||
|
||||
job = self.get(url=f"/service/{service_id}/job", params=params)
|
||||
return job
|
||||
@@ -61,12 +71,21 @@ class JobApiClient(NotifyAdminAPIClient):
|
||||
if job["job_status"] != JobStatus.CANCELLED
|
||||
)
|
||||
|
||||
def get_page_of_jobs(self, service_id, *, page, statuses=None, limit_days=None):
|
||||
def get_page_of_jobs(
|
||||
self,
|
||||
service_id,
|
||||
*,
|
||||
page,
|
||||
statuses=None,
|
||||
limit_days=None,
|
||||
use_processing_time=False,
|
||||
):
|
||||
return self.get_jobs(
|
||||
service_id,
|
||||
statuses=statuses or self.NON_SCHEDULED_JOB_STATUSES,
|
||||
page=page,
|
||||
limit_days=limit_days,
|
||||
use_processing_time=use_processing_time,
|
||||
)
|
||||
|
||||
def get_immediate_jobs(self, service_id):
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import json
|
||||
import secrets
|
||||
from urllib.parse import unquote
|
||||
|
||||
from flask import current_app, request
|
||||
|
||||
from app import redis_client
|
||||
from app.enums import InvitedOrgUserStatus
|
||||
from app.notify_client import NotifyAdminAPIClient, _attach_current_user
|
||||
from notifications_utils.url_safe_token import generate_token
|
||||
|
||||
|
||||
class OrgInviteApiClient(NotifyAdminAPIClient):
|
||||
@@ -15,7 +23,37 @@ class OrgInviteApiClient(NotifyAdminAPIClient):
|
||||
"invite_link_host": self.admin_url,
|
||||
}
|
||||
data = _attach_current_user(data)
|
||||
|
||||
ttl = 24 * 60 * 60
|
||||
# make and store the state
|
||||
state = generate_token(
|
||||
str(request.remote_addr),
|
||||
current_app.config["SECRET_KEY"],
|
||||
current_app.config["DANGEROUS_SALT"],
|
||||
)
|
||||
state_key = f"login-state-{unquote(state)}"
|
||||
redis_client.set(state_key, state, ex=ttl)
|
||||
|
||||
# make and store the nonce
|
||||
nonce = secrets.token_urlsafe()
|
||||
nonce_key = f"login-nonce-{unquote(nonce)}"
|
||||
redis_client.set(nonce_key, nonce, ex=ttl) # save the nonce to redis.
|
||||
|
||||
data["nonce"] = nonce # This is passed to api for the invite url.
|
||||
data["state"] = state # This is passed to api for the invite url.
|
||||
|
||||
resp = self.post(url="/organization/{}/invite".format(org_id), data=data)
|
||||
|
||||
invite_data_key = f"invitedata-{unquote(state)}"
|
||||
# For historical reasons 'invite' signifies a service invite
|
||||
# and 'data' signifies an org invite
|
||||
if resp.get("invite"):
|
||||
redis_invite_data = resp["invite"]
|
||||
else:
|
||||
redis_invite_data = resp["data"]
|
||||
redis_invite_data = json.dumps(redis_invite_data)
|
||||
redis_client.set(invite_data_key, redis_invite_data, ex=ttl)
|
||||
|
||||
return resp["data"]
|
||||
|
||||
def get_invites_for_organization(self, org_id):
|
||||
|
||||
@@ -4,8 +4,12 @@ from app.notify_client import NotifyAdminAPIClient, cache
|
||||
|
||||
class TemplateFolderAPIClient(NotifyAdminAPIClient):
|
||||
@cache.delete("service-{service_id}-template-folders")
|
||||
def create_template_folder(self, service_id, name, parent_id=None):
|
||||
def create_template_folder(
|
||||
self, service_id, name, parent_id=None, created_by_id=None
|
||||
):
|
||||
data = {"name": name, "parent_id": parent_id}
|
||||
if created_by_id:
|
||||
data["created_by_id"] = created_by_id
|
||||
return self.post("/service/{}/template-folder".format(service_id), data)[
|
||||
"data"
|
||||
]["id"]
|
||||
|
||||
@@ -5,6 +5,8 @@ from boto3 import Session
|
||||
from botocore.config import Config
|
||||
from flask import current_app
|
||||
|
||||
from notifications_utils.s3 import S3ObjectNotFound
|
||||
|
||||
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
|
||||
@@ -48,6 +50,19 @@ def get_s3_object(
|
||||
return obj
|
||||
|
||||
|
||||
def check_s3_file_exists(obj):
|
||||
try:
|
||||
obj.load()
|
||||
return True
|
||||
except botocore.exceptions.ClientError as client_error:
|
||||
error_code = client_error.response["Error"]["Code"]
|
||||
if error_code in ["404", "NoSuchKey"]:
|
||||
return False
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_s3_metadata(obj):
|
||||
try:
|
||||
return obj.get()["Metadata"]
|
||||
@@ -55,6 +70,8 @@ def get_s3_metadata(obj):
|
||||
current_app.logger.error(
|
||||
f"Unable to download s3 file {obj.bucket_name}/{obj.key}"
|
||||
)
|
||||
if client_error.response["Error"]["Code"] == "NoSuchKey":
|
||||
raise S3ObjectNotFound(client_error.response, client_error.operation_name)
|
||||
raise client_error
|
||||
|
||||
|
||||
@@ -69,12 +86,13 @@ def set_s3_metadata(obj, **kwargs):
|
||||
|
||||
|
||||
def get_s3_contents(obj):
|
||||
contents = ""
|
||||
try:
|
||||
contents = obj.get()["Body"].read().decode("utf-8")
|
||||
response = obj.get()
|
||||
return response["Body"].read().decode("utf-8")
|
||||
except botocore.exceptions.ClientError as client_error:
|
||||
current_app.logger.error(
|
||||
f"Unable to download s3 file {obj.bucket_name}/{obj.key}"
|
||||
)
|
||||
if client_error.response["Error"]["Code"] == "NoSuchKey":
|
||||
raise S3ObjectNotFound(client_error.response, client_error.operation_name)
|
||||
raise client_error
|
||||
return contents
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
from flask import current_app
|
||||
|
||||
from app.s3_client import (
|
||||
check_s3_file_exists,
|
||||
get_s3_contents,
|
||||
get_s3_metadata,
|
||||
get_s3_object,
|
||||
@@ -15,12 +16,16 @@ NEW_FILE_LOCATION_STRUCTURE = "{}-service-notify/{}.csv"
|
||||
|
||||
|
||||
def get_csv_location(service_id, upload_id):
|
||||
bucket = current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
|
||||
key = NEW_FILE_LOCATION_STRUCTURE.format(service_id, upload_id)
|
||||
region = current_app.config["CSV_UPLOAD_BUCKET"]["region"]
|
||||
|
||||
return (
|
||||
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
|
||||
NEW_FILE_LOCATION_STRUCTURE.format(service_id, upload_id),
|
||||
bucket,
|
||||
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"],
|
||||
region,
|
||||
)
|
||||
|
||||
|
||||
@@ -72,3 +77,7 @@ def set_metadata_on_csv_upload(service_id, upload_id, **kwargs):
|
||||
|
||||
def get_csv_metadata(service_id, upload_id):
|
||||
return get_s3_metadata(get_csv_upload(service_id, upload_id))
|
||||
|
||||
|
||||
def check_s3_report_exists(service_id, upload_id):
|
||||
return check_s3_file_exists(get_csv_upload(service_id, upload_id))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone_obj
|
||||
|
||||
|
||||
def sum_of_statistics(delivery_statistics):
|
||||
@@ -27,7 +26,7 @@ def sum_of_statistics(delivery_statistics):
|
||||
|
||||
|
||||
def add_rates_to(delivery_statistics):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return dict(
|
||||
emails_failure_rate=get_formatted_percentage(
|
||||
delivery_statistics["emails_failed"],
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
{% macro ajax_block(partials, url, key, finished=False, form='') %}
|
||||
{% if not finished %}
|
||||
<div
|
||||
data-module="update-content"
|
||||
data-resource="{{ url }}"
|
||||
data-key="{{ key }}"
|
||||
data-form="{{ form }}"
|
||||
>
|
||||
{% endif %}
|
||||
{{ partials[key]|safe }}
|
||||
{% if not finished %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ partials[key]|safe }}
|
||||
{% endmacro %}
|
||||
|
||||
20
app/templates/components/checkboxes.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% from "components/select-input.html" import select, select_list, select_nested, select_wrapper, select_input %}
|
||||
|
||||
{% macro checkboxes(field, hint=None, disable=[], option_hints={}, hide_legend=False) %}
|
||||
{{ select(field, hint, disable, option_hints, hide_legend, input="checkbox") }}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro checkbox_list(options, child_map, disable=[], option_hints={}) %}
|
||||
{{ select_list(options, child_map, disable, option_hints, input="checkbox") }}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro checkboxes_nested(field, child_map, hint=None, disable=[], option_hints={}, hide_legend=False) %}
|
||||
{{ select_nested(field, child_map, hint, disable, option_hints, hide_legend, input="checkbox") }}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro checkbox(option, disable=[], option_hints={}, data_target=None, as_list_item=False) %}
|
||||
{{ select_input(option, disable, option_hints, data_target, as_list_item, input="checkbox") }}
|
||||
{% endmacro %}
|
||||
@@ -17,7 +17,8 @@
|
||||
{% call govukFieldset({
|
||||
"legend": {
|
||||
"text": field.label.text,
|
||||
"isPageHeading": False
|
||||
"isPageHeading": False,
|
||||
"classes": "text-bold"
|
||||
},
|
||||
"attributes": attributes
|
||||
})
|
||||
@@ -36,9 +37,9 @@
|
||||
{% set autocomplete = "" %}
|
||||
{% endif %}
|
||||
{% if entry.errors %}
|
||||
{% set label_classes = "usa-input--numbered__label usa-input--numbered__label--error" %}
|
||||
{% set label_classes = "usa-label usa-label--error" %}
|
||||
{% else %}
|
||||
{% set label_classes = "usa-input--numbered__label" %}
|
||||
{% set label_classes = "usa-label" %}
|
||||
{% endif %}
|
||||
{% set field_name = field.name + "-" + loop.index|string %}
|
||||
{{ entry(param_extensions={
|
||||
@@ -48,7 +49,7 @@
|
||||
"html": '<span class="usa-sr-only">' + item_name + ' number </span>' + loop.index|string + '.',
|
||||
"classes": label_classes
|
||||
},
|
||||
"classes": "usa-input--numbered ",
|
||||
"classes": "usa-input",
|
||||
"value": field.data[loop.index0],
|
||||
"autocomplete": autocomplete
|
||||
}) }}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{% macro previous_next_navigation(previous_page, next_page) %}
|
||||
{% if previous_page or next_page %}
|
||||
<nav class="govuk-previous-and-next-navigation" role="navigation" aria-label="Pagination">
|
||||
<ul class="group">
|
||||
<nav class="usa-pagination margin-y-5 text-left" role="navigation" aria-label="Pagination">
|
||||
<ul class="usa-pagination__list flex-justify-start">
|
||||
{% if previous_page %}
|
||||
<li class="previous-page">
|
||||
<a class="usa-link" href="{{previous_page['url']}}" rel="previous" >
|
||||
<span class="pagination-part-title">
|
||||
<svg class="pagination-icon" xmlns="http://www.w3.org/2000/svg" height="13" width="17" viewBox="0 0 17 13">
|
||||
<path fill="currentColor" d="m6.5938-0.0078125-6.7266 6.7266 6.7441 6.4062 1.377-1.449-4.1856-3.9768h12.896v-2h-12.984l4.2931-4.293-1.414-1.414z"/>
|
||||
</svg>
|
||||
<li class="usa-pagination__item usa-pagination__arrow">
|
||||
<a class="usa-pagination__link usa-pagination__previous-page" href="{{previous_page['url']}}" rel="previous">
|
||||
<svg class="usa-icon" xmlns="http://www.w3.org/2000/svg" height="13" width="17" viewBox="0 0 17 13">
|
||||
<path fill="currentColor" d="m6.5938-0.0078125-6.7266 6.7266 6.7441 6.4062 1.377-1.449-4.1856-3.9768h12.896v-2h-12.984l4.2931-4.293-1.414-1.414z"/>
|
||||
</svg>
|
||||
<span class="usa-pagination__link-text">
|
||||
{{previous_page['title']}}
|
||||
</span>
|
||||
<span class="pagination-label">{{previous_page['label']}}</span>
|
||||
<span class="usa-sr-only">{{previous_page['label']}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if next_page %}
|
||||
<li class="next-page">
|
||||
<a class="usa-link" href="{{next_page['url']}}" rel="next">
|
||||
<span class="pagination-part-title">
|
||||
<li class="usa-pagination__item usa-pagination__arrow">
|
||||
<a class="usa-pagination__link usa-pagination__next-page" href="{{next_page['url']}}" rel="next">
|
||||
<span class="usa-pagination__link-text">
|
||||
{{next_page['title']}}
|
||||
<svg class="pagination-icon" xmlns="http://www.w3.org/2000/svg" height="13" width="17" viewBox="0 0 17 13">
|
||||
<path fill="currentColor" d="m10.107-0.0078125-1.4136 1.414 4.2926 4.293h-12.986v2h12.896l-4.1855 3.9766 1.377 1.4492 6.7441-6.4062-6.7246-6.7266z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pagination-label">{{next_page['label']}}</span>
|
||||
<svg class="usa-icon" xmlns="http://www.w3.org/2000/svg" height="13" width="17" viewBox="0 0 17 13">
|
||||
<path fill="currentColor" d="m10.107-0.0078125-1.4136 1.414 4.2926 4.293h-12.986v2h12.896l-4.1855 3.9766 1.377 1.4492 6.7441-6.4062-6.7246-6.7266z"/>
|
||||
</svg>
|
||||
<span class="usa-sr-only">{{next_page['label']}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% from "components/select-input.html" import select, select_list, select_nested, select_wrapper, select_input %}
|
||||
|
||||
{% macro radios(field, hint=None, disable=[], option_hints={}, hide_legend=False, inline=False) %}
|
||||
{{ select(field, hint, disable, option_hints, hide_legend, input="radio", inline=inline) }}
|
||||
{% macro radios(field, hint=None, disable=[], option_hints={}, hide_legend=False, inline=False, legend_style="text") %}
|
||||
{{ select(field, hint, disable, option_hints, hide_legend, legend_style=legend_style, input="radio", inline=inline) }}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
{% macro select_list(options, child_map, disable=[], option_hints={}, input="radio", indent_level=0) %}
|
||||
{% for option in options %}
|
||||
{% if child_map[option.data] %}
|
||||
<div class="{% if indent_level > 0 %}margin-left-{{ indent_level * 4 }}{% endif %}">
|
||||
<div class="{% if indent_level == 1 %}margin-left-3{% elif indent_level == 2 %}margin-left-4{% elif indent_level >= 3 %}margin-left-5{% endif %}">
|
||||
{{ select_input(option, disable, option_hints, as_list_item=False, input=input) }}
|
||||
</div>
|
||||
{{ select_list(child_map[option.data], child_map, disable, option_hints, input=input, indent_level=indent_level + 1) }}
|
||||
{% else %}
|
||||
<div class="{% if indent_level > 0 %}margin-left-{{ indent_level * 4 }}{% endif %}">
|
||||
<div class="{% if indent_level == 1 %}margin-left-3{% elif indent_level == 2 %}margin-left-4{% elif indent_level >= 3 %}margin-left-5{% endif %}">
|
||||
{{ select_input(option, disable, option_hints, as_list_item=False, input=input) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -45,7 +45,11 @@
|
||||
<fieldset id="{{ field.id }}" class="usa-fieldset" {% if inline %}class="inline"{% endif %}>
|
||||
<legend class="{{ 'usa-legend' if not hide_legend else '' }}{% if legend_style != 'text' %} {{ legend_style }}{% endif %}">
|
||||
{% if hide_legend %}<span class="usa-sr-only">{% endif %}
|
||||
{{ field.label.text|safe }}
|
||||
{% if legend_style == 'usa-legend--large' %}
|
||||
<h1 class="usa-legend font-sans-lg">{{ field.label.text|safe }}</h1>
|
||||
{% else %}
|
||||
{{ field.label.text|safe }}
|
||||
{% endif %}
|
||||
{% if hide_legend %}</span>{% endif %}
|
||||
{% if hint %}
|
||||
<span class="form-hint">
|
||||
@@ -64,9 +68,9 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro select_input(option, disable=[], option_hints={}, data_target=None, as_list_item=False, input="radio") %}
|
||||
<div class="usa-radio" {% if data_target %}data-target="{{ data_target }}"{% endif %}>
|
||||
<div class="usa-{{ input }}" {% if data_target %}data-target="{{ data_target }}"{% endif %}>
|
||||
<input
|
||||
id="{{ option.id }}" class="usa-radio__input" name="{{ option.name }}" type="{{ input }}" value="{{ option.data }}"
|
||||
id="{{ option.id }}" class="usa-{{ input }}__input" name="{{ option.name }}" type="{{ input }}" value="{{ option.data }}"
|
||||
{% if option.data in disable %}
|
||||
disabled
|
||||
{% endif %}
|
||||
@@ -74,7 +78,7 @@
|
||||
checked
|
||||
{% endif %}
|
||||
>
|
||||
<label class="usa-radio__label" for="{{ option.id }}">
|
||||
<label class="usa-{{ input }}__label" for="{{ option.id }}">
|
||||
{{ option.label.text }}
|
||||
{% if option_hints[option.data] %}
|
||||
<span class="usa-hint">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<p>Notify.gov is an easy-to-use, web-based platform. It requires no technical expertise or system integration — users
|
||||
can create an account and get started within minutes. We take the security and privacy of messaging data seriously
|
||||
by minimizing data retention and using modern encryption methods.</p>
|
||||
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/fkcy_xbo0Bw?si=qNo94BwL4WCeusy5&start=2" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> <h2 class="padding-bottom-3">Product Highlights</h2>
|
||||
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/fkcy_xbo0Bw?si=qNo94BwL4WCeusy5&start=2" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen credentialless></iframe> <h2 class="padding-bottom-3">Product Highlights</h2>
|
||||
{% set product_highlights = [
|
||||
{
|
||||
"svg_src": "#send",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="usa-pagination__item">
|
||||
<a class="usa-pagination__button" href="?page={{ page }}">
|
||||
<a class="usa-pagination__button" href="?page={{ page }}{% if request.args.get('filter') %}&filter={{ request.args.get('filter') }}{% endif %}">
|
||||
{{ page }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -51,14 +51,62 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% if pagination and total_jobs %}
|
||||
<p class="text-center font-body-sm">
|
||||
Page <span class="text-bold">{{ pagination.current }}</span> of <span class="text-bold">{{ pagination.last }}</span> (<span>{{ total_jobs }}</span> total jobs)
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endset %}
|
||||
{% block maincolumn_content %}
|
||||
<style>
|
||||
.download-reports-container .usa-button {
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 48px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.download-reports-container .usa-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
<div class="margin-bottom-8">
|
||||
<h1 class="usa-sr-only">All activity</h1>
|
||||
<h2 class="font-body-2xl line-height-sans-2 margin-0">All activity</h2>
|
||||
<h2 class="margin-top-4 margin-bottom-1">Sent jobs</h2>
|
||||
<div class="usa-table-container--scrollable-mobile">
|
||||
<div class="flex-wrap gap-3 display-flex flex-align-start margin-top-2">
|
||||
<div class="flex-1">
|
||||
<ul class="usa-button-group usa-button-group--segmented">
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{{ url_for('main.all_jobs_activity', service_id=current_service.id) }}"
|
||||
class="usa-button usa-button--small {% if not request.args.get('filter') %}{% else %}usa-button--outline{% endif %}">
|
||||
All
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{{ url_for('main.all_jobs_activity', service_id=current_service.id, filter='24hours') }}"
|
||||
class="usa-button usa-button--small {% if request.args.get('filter') == '24hours' %}{% else %}usa-button--outline{% endif %}">
|
||||
Last 24 hours
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{{ url_for('main.all_jobs_activity', service_id=current_service.id, filter='3days') }}"
|
||||
class="usa-button usa-button--small {% if request.args.get('filter') == '3days' %}{% else %}usa-button--outline{% endif %}">
|
||||
Last 3 days
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{{ url_for('main.all_jobs_activity', service_id=current_service.id, filter='7days') }}"
|
||||
class="usa-button usa-button--small {% if request.args.get('filter') == '7days' %}{% else %}usa-button--outline{% endif %}">
|
||||
Last 7 days
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usa-table-container--scrollable-mobile table-overflow-x-auto">
|
||||
<table class="usa-table usa-table--compact job-table">
|
||||
<caption class="usa-sr-only">Table showing all sent jobs for this service</caption>
|
||||
<thead class="table-field-headings">
|
||||
@@ -66,23 +114,20 @@
|
||||
<th scope="col" role="columnheader" class="table-field-heading-first" id="jobId">
|
||||
<span>Job ID#</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Template</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Started</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Sender</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Report</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Delivered</span>
|
||||
</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Failed</span>
|
||||
<th scope="col" role="columnheader" class="table-field-heading">
|
||||
<span>Status</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -90,28 +135,34 @@
|
||||
{% if all_jobs_dict %}
|
||||
{% for job in all_jobs_dict %}
|
||||
<tr class="table-row">
|
||||
<td class="table-field jobid" role="rowheader">
|
||||
<td class="table-field width-5" role="rowheader">
|
||||
<a class="usa-link" href="{{ job.view_job_link }}">
|
||||
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-field template">{{ job.template_name }}</td>
|
||||
<td data-sort-value="{{ job.activity_time | convert_time_unixtimestamp }}" class="table-field time-sent">
|
||||
<td class="table-field width-16">{{ job.template_name }}</td>
|
||||
<td data-sort-value="{{ job.activity_time | convert_time_unixtimestamp }}" class="table-field width-18">
|
||||
{{ job.activity_time|format_datetime_table }}
|
||||
</td>
|
||||
<td class="table-field sender">{{ job.created_by.name }}</td>
|
||||
<td class="table-field report">
|
||||
<td class="table-field width-30 ">{{ job.created_by.name }}</td>
|
||||
<td class="text-center table-field width-2">
|
||||
{% if job.can_download %}
|
||||
<a href="{{ job.download_link }}">
|
||||
<img src="{{ url_for('static', filename='img/material-icons/file_download.svg') }}" alt="">
|
||||
<img src="{{ url_for('static', filename='img/material-icons/file_download.svg') }}" alt="" class="padding-top-05">
|
||||
<span class="usa-sr-only">Download report link</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span>N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="table-field delivered">{{ job.delivered_count if job.delivered_count is not none else '0' }}</td>
|
||||
<td class="table-field failed">{{ job.failed_count if job.failed_count is not none else '0' }}</td>
|
||||
<td class="table-field width-15">
|
||||
<span class="bg-base-lighter padding-x-05 padding-y-0 font-body-3xs text-bold radius-sm margin-right-05 display-inline-block text-nowrap">
|
||||
{{ job.delivered_count if job.delivered_count is not none else '0' }} delivered
|
||||
</span>
|
||||
<span class="bg-base-lighter padding-x-05 padding-y-0 font-body-3xs text-bold radius-sm display-inline-block text-nowrap">
|
||||
{{ job.failed_count if job.failed_count is not none else '0' }} failed
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
@@ -122,37 +173,104 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
|
||||
<p><b>Note: </b>Report data is only available for 7 days after your message has been sent</p>
|
||||
<p class="font-body-sm"><b>Note: </b>Report data is only available for 7 days after your message has been sent</p>
|
||||
</div>
|
||||
{{show_pagination}}
|
||||
{% if current_user.has_permissions(ServicePermission.VIEW_ACTIVITY) %}
|
||||
{% if has_any_download_data %}
|
||||
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Download recent reports</h2>
|
||||
{% if has_1_day_data %}
|
||||
<p class="font-body-sm">
|
||||
<a href="{{ download_link_one_day }}" download="download" class="usa-link">Download all data last 24 hours (<abbr title="Comma separated values">CSV</abbr>)</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if has_3_day_data %}
|
||||
<p class="font-body-sm">
|
||||
<a href="{{ download_link_three_day }}" download="download" class="usa-link">Download all data last 3 days (<abbr title="Comma separated values">CSV</abbr>)</a>
|
||||
 
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if has_5_day_data %}
|
||||
<p class="font-body-sm">
|
||||
<a href="{{ download_link_five_day }}" download="download" class="usa-link">Download all data last 5 days (<abbr title="Comma separated values">CSV</abbr>)</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if has_7_day_data %}
|
||||
<p class="font-body-sm">
|
||||
<a href="{{ download_link_seven_day }}" download="download" class="usa-link">Download all data last 7 days (<abbr title="Comma separated values">CSV</abbr>)</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Download recent reports</h2>
|
||||
<p class="font-body-sm">No recent activity to download. Download links will appear when jobs are available.</p>
|
||||
{% endif %}
|
||||
<div class="usa-summary-box margin-top-4" role="region" aria-labelledby="download-reports-heading">
|
||||
<div class="usa-summary-box__body">
|
||||
<h2 class="usa-summary-box__heading" id="download-reports-heading">Download recent reports</h2>
|
||||
|
||||
<div class="usa-summary-box__text">
|
||||
<p class="font-body-xs margin-bottom-3">
|
||||
Reports are automatically generated daily at midnight and include data through the previous day.
|
||||
Today's activity will appear in tomorrow's report.
|
||||
</p>
|
||||
|
||||
<div class="download-reports-container maxw-tablet-lg">
|
||||
<div class="margin-bottom-2">
|
||||
{% if report_1_day.available %}
|
||||
<a href="{{ download_link_one_day }}"
|
||||
class="usa-button width-full display-flex flex-align-center"
|
||||
download
|
||||
aria-label="Download yesterday's report, CSV format, {{ report_1_day.size }}">
|
||||
<svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
|
||||
</svg>
|
||||
<span>Yesterday - {{ report_1_day.size }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="usa-button width-full"
|
||||
disabled
|
||||
aria-label="Yesterday's report not available - no messages were sent">
|
||||
Yesterday - No messages sent
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="margin-bottom-2">
|
||||
{% if report_3_day.available %}
|
||||
<a href="{{ download_link_three_day }}"
|
||||
class="usa-button width-full display-flex flex-align-center"
|
||||
download
|
||||
aria-label="Download last 3 days report, CSV format, {{ report_3_day.size }}">
|
||||
<svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
|
||||
</svg>
|
||||
<span>Last 3 days - {{ report_3_day.size }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="usa-button width-full"
|
||||
disabled
|
||||
aria-label="Last 3 days report not available - no messages were sent">
|
||||
Last 3 days - No messages sent
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="margin-bottom-2">
|
||||
{% if report_5_day.available %}
|
||||
<a href="{{ download_link_five_day }}"
|
||||
class="usa-button width-full display-flex flex-align-center"
|
||||
download
|
||||
aria-label="Download last 5 days report, CSV format, {{ report_5_day.size }}">
|
||||
<svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
|
||||
</svg>
|
||||
<span>Last 5 days - {{ report_5_day.size }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="usa-button width-full"
|
||||
disabled
|
||||
aria-label="Last 5 days report not available - no messages were sent">
|
||||
Last 5 days - No messages sent
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="margin-bottom-2">
|
||||
{% if report_7_day.available %}
|
||||
<a href="{{ download_link_seven_day }}"
|
||||
class="usa-button width-full display-flex flex-align-center"
|
||||
download
|
||||
aria-label="Download last 7 days report, CSV format, {{ report_7_day.size }}">
|
||||
<svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
|
||||
</svg>
|
||||
<span>Last 7 days - {{ report_7_day.size }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="usa-button width-full"
|
||||
disabled
|
||||
aria-label="Last 7 days report not available - no messages were sent">
|
||||
Last 7 days - No messages sent
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -59,11 +59,13 @@
|
||||
autocomplete=False
|
||||
) }}
|
||||
|
||||
{{ list_entry(
|
||||
form.phone_numbers,
|
||||
item_name='phone number',
|
||||
autocomplete=False
|
||||
) }}
|
||||
<div class="margin-top-4">
|
||||
{{ list_entry(
|
||||
form.phone_numbers,
|
||||
item_name='phone number',
|
||||
autocomplete=False
|
||||
) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% from "components/banner.html" import banner_wrapper %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/components/back-link/macro.njk" import usaBackLink %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% block service_page_title %}
|
||||
Create an API key
|
||||
@@ -19,7 +20,17 @@
|
||||
|
||||
{% call form_wrapper() %}
|
||||
{{ form.key_name }}
|
||||
{{ form.key_type }}
|
||||
<div class="margin-top-5">
|
||||
{% if current_service.trial_mode %}
|
||||
{{ radios(
|
||||
form.key_type,
|
||||
disable=['normal'],
|
||||
option_hints={'normal': 'Not available because your service is in <a class="usa-link" href="/features/trial-mode">trial mode</a>'|safe}
|
||||
) }}
|
||||
{% else %}
|
||||
{{ radios(form.key_type) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ page_footer('Continue') }}
|
||||
{% endcall %}
|
||||
|
||||
|
||||
@@ -101,9 +101,16 @@
|
||||
{% set button_text %}
|
||||
{{ "Schedule" if scheduled_for else 'Send'}}
|
||||
{% endset %}
|
||||
{{ usaButton({
|
||||
"text": button_text,
|
||||
"name": button_text
|
||||
}) }}
|
||||
<div>
|
||||
{{ usaButton({
|
||||
"text": button_text,
|
||||
"name": button_text
|
||||
}) }}
|
||||
{{ usaButton({
|
||||
"text": "Change CSV file",
|
||||
"classes": "usa-button--outline",
|
||||
"href": url_for('main.send_messages', service_id=current_service.id, template_id=template_id)
|
||||
}) }}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</form>
|
||||
<div id="activityChart">
|
||||
<div class="chart-header">
|
||||
<div class="chart-subtitle">{{ current_service.name }} - last 7 days</div>
|
||||
<div class="chart-subtitle">{{ current_service.name }}</div>
|
||||
<div class="chart-legend" role="region" aria-label="Legend"></div>
|
||||
</div>
|
||||
<div class="chart-container" id="weeklyChart"></div>
|
||||
@@ -22,15 +22,15 @@
|
||||
<div id="tableActivity" class="table-overflow-x-auto">
|
||||
<h2 id="table-heading" class="margin-top-4 margin-bottom-1">Service activity</h2>
|
||||
|
||||
<table class="usa-table job-table" id="activity-table">
|
||||
<table class="usa-table usa-table--compact job-table" id="activity-table">
|
||||
<caption class="usa-sr-only">Table showing the sent jobs for {{current_service.name}}</caption>
|
||||
|
||||
<thead class="table-field-headings">
|
||||
<tr>
|
||||
<th scope="col" class="table-field-heading-first" id="jobId">Job ID#</th>
|
||||
<th data-sortable scope="col" class="table-field-heading" scope="col">Template</th>
|
||||
<th data-sortable scope="col" class="table-field-heading">Job status</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading sender-column">Sender
|
||||
<th data-sortable scope="col" class="table-field-heading width-30">Job status</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading" data-column="sender">Sender
|
||||
</th>
|
||||
<th data-sortable scope="col" class="table-field-heading"># of Recipients</th>
|
||||
</tr>
|
||||
@@ -39,13 +39,13 @@
|
||||
{% if jobs %}
|
||||
{% for job in jobs %}
|
||||
<tr id="{{ job.id }}">
|
||||
<td class="table-field jobid" role="rowheader">
|
||||
<td class="table-field" role="rowheader">
|
||||
<a class="usa-link" href="{{ url_for('.view_job', service_id=current_service.id, job_id=job.id )}}">
|
||||
{{ job.id[:8] if job.id else 'Manually entered number' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-field template">{{ job.template_name }}</td>
|
||||
<td class="table-field time-sent">
|
||||
<td class="table-field width-35">{{ job.template_name }}</td>
|
||||
<td class="table-field width-20">
|
||||
{% if not job.finished_processing %}
|
||||
{% if job.scheduled_for%}
|
||||
Scheduled for {{ job.scheduled_for|format_datetime_table }}
|
||||
@@ -58,8 +58,8 @@
|
||||
Sent on {{ job.processing_started|format_datetime_table }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="table-field sender sender-column">{{ job.created_by.name }}</td>
|
||||
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
|
||||
<td class="table-field" data-sender="{{ job.created_by.name }}">{{ job.created_by.name }}</td>
|
||||
<td class="table-field width-5">{{ job.notification_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% from "components/page-footer.html" import page_footer %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/components/back-link/macro.njk" import usaBackLink %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% block per_page_title %}
|
||||
Set auth type for {{ user.name }}
|
||||
@@ -19,7 +20,9 @@
|
||||
{{ page_header('Set auth type for ' + user.name) }}
|
||||
|
||||
{% call form_wrapper() %}
|
||||
{{ form.auth_type }}
|
||||
<div class="auth-type-radios">
|
||||
{{ radios(form.auth_type) }}
|
||||
</div>
|
||||
{{ page_footer('Save') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
@@ -15,56 +15,44 @@
|
||||
<p class="max-width-full">This page refreshes automatically to show the latest message activity delivery rates, details, and reports.<br>You can watch it in progress or check back later.</p>
|
||||
{% endif %}
|
||||
<div data-job-id="{{ job.id }}" data-feature="{{FEATURE_SOCKET_ENABLED | lower}}" data-host="{{ api_public_url }}">
|
||||
{% if not job.finished_processing %}
|
||||
{% if not job.finished_processing and FEATURE_SOCKET_ENABLED %}
|
||||
<div
|
||||
data-resource="{{ updates_url }}"
|
||||
{% if FEATURE_SOCKET_ENABLED %}
|
||||
data-socket-update="status"
|
||||
{% else %}
|
||||
data-module="update-content"
|
||||
{% endif %}
|
||||
data-socket-update="status"
|
||||
data-key="status"
|
||||
data-form=""
|
||||
>
|
||||
{% endif %}
|
||||
{{ partials['status']|safe }}
|
||||
{% if not job.finished_processing %}
|
||||
{% if not job.finished_processing and FEATURE_SOCKET_ENABLED %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not finished %}
|
||||
{% if not finished and FEATURE_SOCKET_ENABLED %}
|
||||
<div
|
||||
data-resource="{{ updates_url }}"
|
||||
{% if FEATURE_SOCKET_ENABLED %}
|
||||
data-socket-update="counts"
|
||||
{% else %}
|
||||
data-module="update-content"
|
||||
{% endif %}
|
||||
data-socket-update="counts"
|
||||
data-key="counts"
|
||||
data-form=""
|
||||
>
|
||||
{% endif %}
|
||||
{{ partials['counts']|safe }}
|
||||
{% if not finished %}
|
||||
{% if not finished and FEATURE_SOCKET_ENABLED %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="notification-status max-width-full">
|
||||
Messages are sent immediately to the cell phone carrier, but will remain in "pending" status until we hear back from the carrier they have received it and attempted deliver. More information on <a class="usa-link" href="{{ url_for('main.message_status') }}">delivery status</a>.
|
||||
</p>
|
||||
{% if not job.processing_finished %}
|
||||
{% if not job.processing_finished and FEATURE_SOCKET_ENABLED %}
|
||||
<div
|
||||
data-resource="{{ updates_url }}"
|
||||
{% if FEATURE_SOCKET_ENABLED %}
|
||||
data-socket-update="notifications"
|
||||
{% else %}
|
||||
data-module="update-content"
|
||||
{% endif %}
|
||||
data-socket-update="notifications"
|
||||
data-key="notifications"
|
||||
data-form=""
|
||||
>
|
||||
{% endif %}
|
||||
{{ partials['notifications']|safe }}
|
||||
{% if not job.processing_finished %}
|
||||
{% if not job.processing_finished and FEATURE_SOCKET_ENABLED %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -86,13 +86,17 @@
|
||||
</ul>
|
||||
{% if current_service.all_template_folders %}
|
||||
<p class="usa-body tick-cross-list-hint">
|
||||
{% set folder_count = user.template_folders_for_service(current_service) | length %}
|
||||
{% if folder_count == 0 %}
|
||||
Cannot see any folders
|
||||
{% elif folder_count != current_service.all_template_folders | length %}
|
||||
Can see {{ folder_count }} folder{% if folder_count > 1 %}s{% endif %}
|
||||
{% if user.platform_admin %}
|
||||
Platform admin can see all folders
|
||||
{% else %}
|
||||
Can see all folders
|
||||
{% set folder_count = user.template_folders_for_service(current_service) | length %}
|
||||
{% if folder_count == 0 %}
|
||||
Cannot see any folders
|
||||
{% elif folder_count != current_service.all_template_folders | length %}
|
||||
Can see {{ folder_count }} folder{% if folder_count > 1 %}s{% endif %}
|
||||
{% else %}
|
||||
Can see all folders
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
{% endif %}
|
||||
|
||||
{% if service_has_email_auth %}
|
||||
{% if not mobile_number %}
|
||||
{{ radios(
|
||||
form.login_authentication,
|
||||
disable=['sms_auth'],
|
||||
option_hints={'sms_auth': 'Not available because this team member has not added a phone number to their profile'|safe}
|
||||
) }}
|
||||
{% else %}
|
||||
{{ radios(form.login_authentication) }}
|
||||
{% endif %}
|
||||
<div class="login-auth-radios">
|
||||
{% if not mobile_number %}
|
||||
{{ radios(
|
||||
form.login_authentication,
|
||||
disable=['sms_auth'],
|
||||
option_hints={'sms_auth': 'Not available because this team member has not added a phone number to their profile'|safe}
|
||||
) }}
|
||||
{% else %}
|
||||
{{ radios(form.login_authentication) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/page-footer.html" import page_footer %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% block per_page_title %}
|
||||
Create an account
|
||||
@@ -40,7 +41,7 @@ Create an account
|
||||
{{ page_footer("Continue") }}
|
||||
{{form.service}}
|
||||
{{form.email_address}}
|
||||
{{form.auth_type}}
|
||||
{{ radios(form.auth_type) }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/page-footer.html" import page_footer %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% block per_page_title %}
|
||||
Create an account
|
||||
@@ -26,7 +27,7 @@ Create an account
|
||||
}) }}
|
||||
</div>
|
||||
{{ form.password(param_extensions={"hint": {"text": "At least 8 characters"}, "autocomplete": "new-password"}) }}
|
||||
{{form.auth_type}}
|
||||
{{ radios(form.auth_type) }}
|
||||
{{ page_footer("Continue") }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% from "components/page-footer.html" import page_footer %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/components/back-link/macro.njk" import usaBackLink %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% block service_page_title %}
|
||||
Set data retention
|
||||
@@ -16,7 +17,9 @@
|
||||
{{ page_header('Set data retention') }}
|
||||
|
||||
{% call form_wrapper() %}
|
||||
{{ form.notification_type }}
|
||||
<div class="notification-type-radios">
|
||||
{{ radios(form.notification_type) }}
|
||||
</div>
|
||||
{{ form.days_of_retention }}
|
||||
{{ page_footer('Add') }}
|
||||
{% endcall %}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% from "components/live-search.html" import live_search %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/components/back-link/macro.njk" import usaBackLink %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% set page_title = "Link service to organization" %}
|
||||
|
||||
@@ -26,7 +27,9 @@
|
||||
) }}
|
||||
{% call form_wrapper(data_force_focus=True) %}
|
||||
{% if has_organizations %}
|
||||
{{ form.organizations }}
|
||||
<div class="organizations-radios">
|
||||
{{ radios(form.organizations) }}
|
||||
</div>
|
||||
{{ sticky_page_footer('Save') }}
|
||||
{% else %}
|
||||
<p class="hint"> No organizations </p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% from "components/page-footer.html" import page_footer %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/components/select/macro.njk" import usaSelect -%}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% block per_page_title %}
|
||||
Set up your profile
|
||||
@@ -68,7 +69,7 @@ Set up your profile
|
||||
]
|
||||
})
|
||||
}}-->
|
||||
{{form.auth_type}}
|
||||
{{ radios(form.auth_type) }}
|
||||
{{ page_footer("Save") }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
{% from "components/checkboxes.html" import checkboxes %}
|
||||
|
||||
{% macro format_item_name(name, separators=True) -%}
|
||||
{%- if name is string -%}
|
||||
{{- name -}}
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
}) }}
|
||||
{% if current_user.has_permissions(ServicePermission.MANAGE_SERVICE) and form.users_with_permission.all_service_users %}
|
||||
{{ form.users_with_permission }}
|
||||
{% if form.platform_admins is defined and form.platform_admins %}
|
||||
<p class="usa-hint">Platform admin can see all folders:</p>
|
||||
<ul class="usa-hint usa-list">
|
||||
{% for admin in form.platform_admins %}
|
||||
<li>{{ admin.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{{ page_footer(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% from "components/page-footer.html" import page_footer %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/components/back-link/macro.njk" import usaBackLink %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% block service_page_title %}
|
||||
{{ sender_context.title }}
|
||||
@@ -15,20 +16,11 @@
|
||||
|
||||
<div class="grid-row">
|
||||
{% call form_wrapper() %}
|
||||
{{ form.sender(param_extensions={
|
||||
'fieldset': {
|
||||
'legend': {
|
||||
'isPageHeading': True,
|
||||
'text': sender_context.title
|
||||
}
|
||||
},
|
||||
'classes': 'grid-col-9'
|
||||
}) }}
|
||||
{{ radios(form.sender, legend_style='usa-legend--large', option_hints=option_hints) }}
|
||||
<div class="grid-col-9">
|
||||
{{ page_footer('Continue') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% from "components/page-footer.html" import page_footer %}
|
||||
{% from "components/form.html" import form_wrapper %}
|
||||
{% from "components/components/back-link/macro.njk" import usaBackLink %}
|
||||
{% from "components/radios.html" import radios %}
|
||||
|
||||
{% set page_title = 'Set letter contact block' %}
|
||||
|
||||
@@ -17,19 +18,10 @@
|
||||
|
||||
<div class="grid-row">
|
||||
{% call form_wrapper() %}
|
||||
{{ form.sender(param_extensions={
|
||||
'fieldset': {
|
||||
'legend': {
|
||||
'isPageHeading': True,
|
||||
'text': page_title
|
||||
}
|
||||
},
|
||||
'classes': 'grid-col-9'
|
||||
}) }}
|
||||
{{ radios(form.sender) }}
|
||||
<div class="grid-col-9">
|
||||
{{ page_footer('Continue') }}
|
||||
<a class="usa-link" href="{{ url_for('.service_add_letter_contact', service_id=current_service.id, from_template=template_id) }}">Add new sender</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import pytz
|
||||
from flask import current_app, json
|
||||
from flask_login import current_user
|
||||
|
||||
@@ -179,26 +179,72 @@ def generate_notifications_csv(**kwargs):
|
||||
raise Exception("Should never reach here")
|
||||
|
||||
|
||||
def convert_report_date_to_preferred_timezone(db_date_str_in_utc):
|
||||
def convert_report_date_to_preferred_timezone(db_date_str_in_utc, target_timezone=None):
|
||||
"""
|
||||
Report dates in the db are in UTC. We need to convert them to the user's default timezone,
|
||||
which defaults to "US/Eastern"
|
||||
Format: "2025-05-14 07:22:18 AM America/Los_Angeles"
|
||||
|
||||
Args:
|
||||
db_date_str_in_utc: UTC datetime string in format "YYYY-MM-DD HH:MM:SS"
|
||||
target_timezone: Optional timezone name to use (e.g. "US/Eastern", "America/Los_Angeles")
|
||||
If not provided, will use current user's preference or default
|
||||
"""
|
||||
date_arr = db_date_str_in_utc.split(" ")
|
||||
db_date_str_in_utc = f"{date_arr[0]}T{date_arr[1]}+00:00"
|
||||
utc_date_obj = datetime.datetime.fromisoformat(db_date_str_in_utc)
|
||||
try:
|
||||
date_arr = db_date_str_in_utc.split(" ")
|
||||
db_date_str_in_utc = f"{date_arr[0]}T{date_arr[1]}+00:00"
|
||||
utc_date_obj = datetime.datetime.fromisoformat(db_date_str_in_utc)
|
||||
|
||||
utc_date_obj = utc_date_obj.astimezone(pytz.utc)
|
||||
preferred_timezone = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_date_obj = utc_date_obj.astimezone(preferred_timezone)
|
||||
preferred_tz_created_at = preferred_date_obj.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
utc_date_obj = utc_date_obj.replace(tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
return f"{preferred_tz_created_at} {get_user_preferred_timezone()}"
|
||||
if not target_timezone:
|
||||
target_timezone = get_user_preferred_timezone()
|
||||
|
||||
preferred_timezone = ZoneInfo(target_timezone)
|
||||
preferred_date_obj = utc_date_obj.astimezone(preferred_timezone)
|
||||
|
||||
formatted_date = preferred_date_obj.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
preferred_tz_created_at = f"{formatted_date} {target_timezone}"
|
||||
return preferred_tz_created_at
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
current_app.logger.error(
|
||||
f"Error converting timezone for {db_date_str_in_utc}: {e}"
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return f"{db_date_str_in_utc} UTC"
|
||||
|
||||
|
||||
_timezone_cache = {}
|
||||
|
||||
|
||||
def get_user_preferred_timezone():
|
||||
if current_user and hasattr(current_user, "preferred_timezone"):
|
||||
tz = current_user.preferred_timezone
|
||||
if tz in pytz.all_timezones:
|
||||
return tz
|
||||
try:
|
||||
if (
|
||||
current_user
|
||||
and current_user.is_authenticated
|
||||
and hasattr(current_user, "preferred_timezone")
|
||||
):
|
||||
tz = current_user.preferred_timezone
|
||||
if tz:
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
return tz
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
except (AttributeError, RuntimeError):
|
||||
# AttributeError: current_user might not have expected attributes
|
||||
# RuntimeError: working outside of request context
|
||||
pass
|
||||
|
||||
return "US/Eastern"
|
||||
|
||||
|
||||
def get_user_preferred_timezone_obj():
|
||||
"""Get the ZoneInfo object for the user's preferred timezone, cached for performance."""
|
||||
tz_name = get_user_preferred_timezone()
|
||||
if tz_name not in _timezone_cache:
|
||||
_timezone_cache[tz_name] = ZoneInfo(tz_name)
|
||||
return _timezone_cache[tz_name]
|
||||
|
||||
63
app/utils/s3_csv.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.utils.csv import (
|
||||
convert_report_date_to_preferred_timezone,
|
||||
get_user_preferred_timezone,
|
||||
)
|
||||
|
||||
|
||||
def convert_s3_csv_timestamps(csv_content, user_timezone=None):
|
||||
"""
|
||||
Convert UTC timestamps in CSV to user's preferred timezone.
|
||||
|
||||
Args:
|
||||
csv_content: The CSV content as string or bytes
|
||||
user_timezone: Optional pre-captured timezone (to avoid context issues in streaming)
|
||||
"""
|
||||
if isinstance(csv_content, bytes):
|
||||
csv_content = csv_content.decode("utf-8")
|
||||
|
||||
if user_timezone is None:
|
||||
user_timezone = get_user_preferred_timezone()
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_content))
|
||||
|
||||
time_column_index = None
|
||||
try:
|
||||
header = next(reader)
|
||||
for i, col in enumerate(header):
|
||||
if col.strip().lower() == "time":
|
||||
time_column_index = i
|
||||
break
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(header)
|
||||
yield output.getvalue()
|
||||
output.truncate(0)
|
||||
output.seek(0)
|
||||
except StopIteration:
|
||||
return
|
||||
|
||||
if time_column_index is None:
|
||||
for row in reader:
|
||||
writer.writerow(row)
|
||||
yield output.getvalue()
|
||||
output.truncate(0)
|
||||
output.seek(0)
|
||||
return
|
||||
|
||||
for row in reader:
|
||||
if len(row) > time_column_index and row[time_column_index]:
|
||||
try:
|
||||
row[time_column_index] = convert_report_date_to_preferred_timezone(
|
||||
row[time_column_index], target_timezone=user_timezone
|
||||
)
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
|
||||
writer.writerow(row)
|
||||
yield output.getvalue()
|
||||
output.truncate(0)
|
||||
output.seek(0)
|
||||
@@ -1,13 +1,13 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone_obj
|
||||
|
||||
|
||||
def get_current_financial_year():
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
now = datetime.now(preferred_tz)
|
||||
current_year = int(now.strftime("%Y"))
|
||||
return current_year
|
||||
@@ -15,7 +15,7 @@ def get_current_financial_year():
|
||||
|
||||
def is_less_than_days_ago(date_from_db, number_of_days):
|
||||
return (
|
||||
datetime.utcnow().astimezone(pytz.utc) - parser.parse(date_from_db)
|
||||
datetime.now(ZoneInfo("UTC")) - parser.parse(date_from_db)
|
||||
).days < number_of_days
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ from app.enums import ServicePermission
|
||||
|
||||
permission_mappings = {
|
||||
# TODO: consider turning off email-sending permissions during SMS pilot
|
||||
ServicePermission.SEND_MESSAGES: [ServicePermission.SEND_TEXTS, ServicePermission.SEND_EMAILS],
|
||||
ServicePermission.SEND_MESSAGES: [
|
||||
ServicePermission.SEND_TEXTS,
|
||||
ServicePermission.SEND_EMAILS,
|
||||
],
|
||||
ServicePermission.MANAGE_TEMPLATES: [ServicePermission.MANAGE_TEMPLATES],
|
||||
ServicePermission.MANAGE_SERVICE: [
|
||||
ServicePermission.MANAGE_USERS,
|
||||
|
||||
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 938 KiB After Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 423 KiB |
@@ -1,7 +1,7 @@
|
||||
env: demo
|
||||
instances: 1
|
||||
memory: 1G
|
||||
command: newrelic-admin run-program gunicorn -c /home/vcap/app/gunicorn_config.py application
|
||||
command: newrelic-admin run-program gunicorn -c /home/vcap/app/gunicorn_config.py gunicorn_entry:application
|
||||
public_admin_route: notify-demo.app.cloud.gov
|
||||
cloud_dot_gov_route: notify-demo.app.cloud.gov
|
||||
redis_enabled: 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
env: production
|
||||
instances: 2
|
||||
memory: 2G
|
||||
command: newrelic-admin run-program gunicorn -c /home/vcap/app/gunicorn_config.py application
|
||||
command: newrelic-admin run-program gunicorn -c /home/vcap/app/gunicorn_config.py gunicorn_entry:application
|
||||
public_admin_route: beta.notify.gov
|
||||
cloud_dot_gov_route: notify.app.cloud.gov
|
||||
redis_enabled: 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
env: sandbox
|
||||
instances: 1
|
||||
memory: 1G
|
||||
command: gunicorn -c /home/vcap/app/gunicorn_config.py application
|
||||
command: gunicorn -c /home/vcap/app/gunicorn_config.py gunicorn_entry:application
|
||||
public_admin_route: notify-sandbox.app.cloud.gov
|
||||
cloud_dot_gov_route: notify-sandbox.app.cloud.gov
|
||||
redis_enabled: 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
env: staging
|
||||
instances: 1
|
||||
memory: 1G
|
||||
command: newrelic-admin run-program gunicorn -c /home/vcap/app/gunicorn_config.py application
|
||||
command: newrelic-admin run-program gunicorn -c /home/vcap/app/gunicorn_config.py gunicorn_entry:application
|
||||
public_admin_route: notify-staging.app.cloud.gov
|
||||
cloud_dot_gov_route: notify-staging.app.cloud.gov
|
||||
redis_enabled: 1
|
||||
|
||||
@@ -44,7 +44,6 @@ const javascripts = () => {
|
||||
.pipe(buffer())
|
||||
.pipe(
|
||||
plugins.addSrc.prepend([
|
||||
paths.npm + 'hogan.js/dist/hogan-3.0.2.js',
|
||||
paths.npm + 'jquery/dist/jquery.min.js',
|
||||
paths.npm + 'query-command-supported/dist/queryCommandSupported.min.js',
|
||||
paths.npm + 'textarea-caret/index.js',
|
||||
@@ -61,7 +60,6 @@ const javascripts = () => {
|
||||
paths.src + 'javascripts/enhancedTextbox.js',
|
||||
paths.src + 'javascripts/fileUpload.js',
|
||||
paths.src + 'javascripts/radioSelect.js',
|
||||
paths.src + 'javascripts/updateContent.js',
|
||||
paths.src + 'javascripts/listEntry.js',
|
||||
paths.src + 'javascripts/liveSearch.js',
|
||||
paths.src + 'javascripts/errorTracking.js',
|
||||
@@ -82,6 +80,7 @@ const javascripts = () => {
|
||||
paths.src + 'javascripts/sidenav.js',
|
||||
paths.src + 'javascripts/validation.js',
|
||||
paths.src + 'javascripts/socketio.js',
|
||||
paths.src + 'javascripts/scrollPosition.js',
|
||||
])
|
||||
.pipe(plugins.prettyerror())
|
||||
.pipe(
|
||||
|
||||
@@ -10,7 +10,9 @@ import gunicorn
|
||||
# but we have an unusual configuration with a lot of cpus and not much memory
|
||||
# so adjust it.
|
||||
workers = multiprocessing.cpu_count()
|
||||
worker_class = "eventlet"
|
||||
worker_class = "gevent"
|
||||
|
||||
timeout = 240
|
||||
bind = "0.0.0.0:{}".format(os.getenv("PORT"))
|
||||
disable_redirect_access_to_syslog = True
|
||||
gunicorn.SERVER_SOFTWARE = "None"
|
||||
|
||||
5
gunicorn_entry.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from gevent import monkey
|
||||
|
||||
monkey.patch_all()
|
||||
|
||||
from application import application # noqa
|
||||