Compare commits

..

4 Commits

Author SHA1 Message Date
Cliff Hill
363ea9e744 Removed "expired" flag
Signed-off-by: Cliff Hill <clifford.hill@gsa.gov>
2023-11-08 16:54:14 -05:00
Cliff Hill
59419706be Added expired flag to InvitedUser. Defaulted to False.
Signed-off-by: Cliff Hill <clifford.hill@gsa.gov>
2023-11-07 12:35:18 -05:00
Cliff Hill
8d6f62cde1 Removed .python-version.
Signed-off-by: Cliff Hill <Clifford.hill@gsa.gov>
2023-11-03 14:11:15 -04:00
Cliff Hill
e8aa3158a5 Adjusting .gitignore to exclude .python-version and .node-version.
Signed-off-by: Cliff Hill <Clifford.hill@gsa.gov>
2023-11-03 14:09:52 -04:00
209 changed files with 11050 additions and 4678 deletions

View File

@@ -1,71 +0,0 @@
name: "Bug Report"
description: "Use this template to file a bug report"
title: "Bug: [Brief description of the bug]"
labels:
- "Type: Bug"
body:
- type: markdown
attributes:
value: |
Please use this form to file new bugs! Add screenshots to the comments of this initial form.
- type: input
id: contact
attributes:
label: Who discovered this?
description: Was it something you found or are you filing for someone else? If someone else, how can we contact them?
placeholder: ex. email@example.com
validations:
required: false
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: dropdown
id: environment
attributes:
label: Environment
description: In which environment did you experience the bug?
options:
- staging
- demo
- production
validations:
required: true
- type: textarea
id: page
attributes:
label: What pages did this appear on?
description: please include URLs of where you saw the bug
validations:
required: true
- type: textarea
id: reproSteps
attributes:
label: "Detail the steps for someone to reproduce"
description: "Reproduction steps"
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell

View File

@@ -1 +0,0 @@
blank_issues_enabled: true

View File

@@ -1,76 +0,0 @@
name: "User Story Template"
description: "Use this template for creating user stories"
title: "User Story: [Brief description of the user story]"
labels:
- "Type: User Story"
body:
- type: markdown
attributes:
value: '**User Story:**'
- type: textarea
id: userType
attributes:
label: "As a [type of user],"
description: "Describe the type of user involved in this story."
validations:
required: true
- type: textarea
id: actionFeature
attributes:
label: "I want [an action or feature],"
description: "Describe the desired action or feature from the user's perspective."
validations:
required: true
- type: textarea
id: benefitValue
attributes:
label: "So that [benefit or value]."
description: "Describe the benefit or value the user expects from the action or feature."
validations:
required: true
- type: markdown
attributes:
value: '**Acceptance Criteria:**'
- type: textarea
id: acceptanceCriteria
attributes:
label: "Detailed condition or criteria that must be met for the user story to be considered complete."
description: "List the acceptance criteria for the user story."
validations:
required: true
- type: markdown
attributes:
value: '**Tasks:**'
- type: textarea
id: tasks
attributes:
label: "List of specific tasks or sub-tasks that need to be done to implement the user story."
description: "Outline the tasks necessary to implement the user story."
validations:
required: true
- type: markdown
attributes:
value: '**Dependencies:**'
- type: textarea
id: dependencies
attributes:
label: "List any dependencies that need to be resolved before starting or completing this user story."
description: "Specify any dependencies related to the user story."
validations:
required: false
- type: markdown
attributes:
value: '**Notes:**'
- type: textarea
id: notes
attributes:
label: "Any additional information or context that might be useful for the team."
description: "Provide any extra notes or context for the user story."
validations:
required: false

View File

@@ -1,82 +0,0 @@
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]"
body:
- type: checkboxes
id: Logging_in
attributes:
label: Log In
options:
- label: notify-staging.app.cloud.gov resolves
- label: Clicking sign in takes you to login page
- label: 2FA code is sent promptly
- label: 2FA code input screen appears as expected
- label: After code is entered, you are brought to the “choose service” screen
- label: Choosing a service brings you to the dashboard for that service
- type: checkboxes
id: send_template
attributes:
label: Create a template and send
options:
- label: Clicking “Send messages” brings you to templates page
- label: When “Create a template” button is clicked, the option to create new or copy existing template appears
- label: Uploading a csv is quick and automatically directs you to the preview/send page
- 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
attributes:
label: Send a message from copied template
options:
- label: When “Create a template” button is clicked, the option to create new or copy existing template appears
- label: Clicking “copy an existing template” allows you to choose from pre-existing template
- label: After editing, the new template saves and you prepare to send
- type: checkboxes
id: send__existing_template
attributes:
label: Send a message from existing template
options:
- label: Clicking on existing template brings you to preview screen with `prepare to send` button
- label: All else above persists as true (formatting, send, dashboard, etc)
- type: checkboxes
id: download_report
attributes:
label: Download the csv delivery report
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
- type: checkboxes
id: invite_member
attributes:
label: Invite a team member
options:
- label: Clicking on “Team” in settings brings you to team member screen
- label: Clicking “Invite a team member” brings you to email and permissions screen
- label: Clicking invite team member sends an invite and shows confirmation
- label: Invite email provides live link that works to register new team member
- type: checkboxes
id: delete_member
attributes:
label: Delete a team member
options:
- label: From team member screen, clicking “Change Details” on a specific profile brings you to that persons profile
- label: Clicking on “Remove this team member” prompts a confirmation
- label: Clicking the confirmation removes the team member
- type: checkboxes
id: sign_out
attributes:
label: Sign Out
options:
- label: Hitting “Sign Out” takes you back to the homepage and logs you out

View File

@@ -18,7 +18,7 @@ jobs:
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v41.0.0
uses: tj-actions/changed-files@v1.1.2
with:
files: |
terraform/demo
@@ -54,7 +54,6 @@ jobs:
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -68,11 +67,10 @@ jobs:
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var LOGIN_PEM="$LOGIN_PEM"
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v41.0.0
uses: tj-actions/changed-files@v34
with:
files: |
deploy-config/egress_proxy/notify-admin-demo.*.acl

View File

@@ -18,7 +18,7 @@ jobs:
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v41.0.0
uses: tj-actions/changed-files@v1.1.2
with:
files: |
terraform/production
@@ -54,8 +54,6 @@ jobs:
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -69,11 +67,10 @@ jobs:
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var LOGIN_PEM="$LOGIN_PEM"
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v41.0.0
uses: tj-actions/changed-files@v34
with:
files: |
deploy-config/egress_proxy/notify-admin-production.*.acl

View File

@@ -23,7 +23,7 @@ jobs:
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v41.0.0
uses: tj-actions/changed-files@v1.1.2
with:
files: |
terraform/staging
@@ -51,7 +51,6 @@ jobs:
- name: Create requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: 18f/cg-deploy-action@main
env:
@@ -60,14 +59,6 @@ jobs:
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov"
LOGIN_DOT_GOV_USER_INFO_URL: "https://idp.int.identitysandbox.gov/api/openid_connect/userinfo"
LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://idp.int.identitysandbox.gov/api/openid_connect/token"
LOGIN_DOT_GOV_LOGOUT_URL: "https://idp.int.identitysandbox.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://notify-staging.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://idp.int.identitysandbox.gov/openid_connect/logout?"
LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://notify-staging.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_INITIAL_SIGNIN_URL: "https://idp.int.identitysandbox.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&nonce=01234567890123456789012345&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/sign-in&response_type=code&scope=openid+email&state=abcdefghijklmnopabcdefghijklmnop"
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -81,19 +72,10 @@ jobs:
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var LOGIN_PEM="$LOGIN_PEM"
--var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID"
--var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL"
--var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL"
--var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL"
--var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL"
--var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT"
--var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL"
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v41.0.0
uses: tj-actions/changed-files@v34
with:
files: |
deploy-config/egress_proxy/notify-admin-staging.*.acl

4
.gitignore vendored
View File

@@ -127,8 +127,6 @@ terraform.tfstate.backup
# Playwright
playwright/
# Pyenv
# Pyenv/Nodenv
.python-version
# Nodenv
.node-version

View File

@@ -1,13 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: debug-statements
- id: check-merge-conflict
- id: check-toml

View File

@@ -4,6 +4,3 @@
##
export NEW_RELIC_PROXY_HOST=$egress_proxy
export http_proxy=$egress_proxy
export https_proxy=$egress_proxy
export no_proxy="apps.internal"

View File

@@ -15,11 +15,8 @@ NVMSH := $(shell [ -f "$(HOME)/.nvm/nvm.sh" ] && echo "$(HOME)/.nvm/nvm.sh" || e
.PHONY: bootstrap
bootstrap: generate-version-file ## Set up everything to run the app
poetry self update
poetry self add poetry-dotenv-plugin
poetry install --sync --no-root
poetry install --sync
poetry run playwright install --with-deps
poetry run pre-commit install
source $(NVMSH) --no-use && nvm install && npm ci --no-audit
source $(NVMSH) && npm run build

455
README.md
View File

@@ -1,8 +1,6 @@
# Notify.gov Admin UI
# Notify UI
This is the Notify front-end for government users and admins. To see it in
action, check out [the demo site](https://notify-demo.app.cloud.gov)
(contact team for credentials).
This is the Notify front-end for government users and admins. To see it in action, check out [the demo site](https://notify-demo.app.cloud.gov) (contact team for credentials).
Through the interface, users can:
@@ -11,372 +9,69 @@ Through the interface, users can:
- Send batch SMS by uploading a CSV
- View their history of notifications
The [Notify.gov API](https://github.com/GSA/notifications-api) provides the
UI's backend and is required for most things to function. Set that up first!
The [Notify API](https://github.com/GSA/notifications-api) provides the UI's backend and is required for most things to function. Set that up first!
Our other repositories are:
## Local setup
- [notifications-admin](https://github.com/GSA/notifications-admin)
- [notifications-utils](https://github.com/GSA/notifications-utils)
- [us-notify-compliance](https://github.com/GSA/us-notify-compliance/)
- [notify-python-demo](https://github.com/GSA/notify-python-demo)
### Common steps
## Before You Start
1. Install pre-requisites for setup (on a Mac):
- Install XCode or at least the XCode Command Line Tools
- [Homebrew](https://brew.sh/) (follow instructions on page)
- [jq](https://stedolan.github.io/jq/): `brew install jq`
- [terraform](https://www.terraform.io/): `brew install terraform` or `brew install tfenv` and use `tfenv` to install `terraform ~> 1.4.0`
- [cf-cli@8](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html): `brew install cloudfoundry/tap/cf-cli@8`
1. [Log into cloud.gov](https://cloud.gov/docs/getting-started/setup/#set-up-the-command-line): `cf login -a api.fr.cloud.gov --sso`
1. Ensure you have access to the `notify-local-dev` and `notify-staging` spaces in cloud.gov
1. Run the API setup steps
1. Run the development terraform with:
You will need the following items:
```
$ cd terraform/development
$ ./run.sh
```
- An active cloud.gov account with the correct permissions - speak with your
onboarding buddy for help with
[setting up an account](https://cloud.gov/sign-up/) (requires a `.mil`,
`.gov`, or `.fed.us` email address) and getting access to the
`notify-local-dev` and `notify-staging` spaces.
- Admin priviliges and SSH access on your machine; you may need to work with
your organization's IT support staff if you're not sure or don't currently
have this access.
1. If you want to send data to New Relic from your local develpment environment, set `NEW_RELIC_LICENSE_KEY` within `.env`
1. Follow the instructions for either `Direct installation` or `Docker installation` below
**In addition, you should set up the Notify.gov API first!**
### Direct installation
[Follow the instructions here to set up the Notify.gov API.](https://github.com/GSA/notifications-api#before-you-start)
1. Get the API running
The Notify.gov API is required in order for the Notify.gov Admin UI to run, and
it will also take care of many of the steps that are listed here. The sections
that are a repeat from the API setup are flagged with an **[API Step]** label
in front of them.
1. Install [poetry](https://python-poetry.org/docs/#installation)
## Local Environment Setup
1. Install [nvm (Node Version Manager)](https://github.com/nvm-sh/nvm#installing-and-updating)
This project is set up to work with
[nvm (Node Version Manager)](https://github.com/nvm-sh/nvm#installing-and-updating)
for managing and using Node.js (version 16.15.1) and the `npm` package manager.
1. Install Python and Node dependencies
These instructions will walk you through how to set your machine up with all of
the required tools for this project.
`make bootstrap`
If you run into certificate errors at the `playwright install` step, try doing this:
1. Run `brew --prefix` to see Homebrew's root directory
1. Create or modify the local `.env` file in the project and add this line:
`NODE_EXTRA_CA_CERTS=/CHANGE-TO-HOMEBREW-INSTALL-PATH/etc/ca-certificates/cert.pem`
Make sure to change `CHANGE-TO-HOMEBREW-INSTALL-PATH` to the path given by `brew --prefix` in the step above.
For example, if `brew --prefix` gave `/opt/homebrew` as output, then the line would look like this:
`NODE_EXTRA_CA_CERTS=/opt/homebrew/etc/ca-certificates/cert.pem`
1. Save the changes to the `.env` file
1. Run `make bootstrap` again
1. Run the Flask server
`make run-flask`
1. Go to http://localhost:6012
### Docker installation
If you are using VS Code, there are also instructions for
[running inside Docker](./docs/docker-remote-containers.md).
### [API Step] Project Pre-Requisite Setup
On MacOS, using [Homebrew](https://brew.sh/) for package management is highly
recommended. This helps avoid some known installation issues. Start by following
the installation instructions on the Homebrew homepage.
**Note:** You will also need Xcode or the Xcode Command Line Tools installed. The
quickest way to do this is is by installing the command line tools in the shell:
```sh
xcode-select -install
```
#### [API Step] Homebrew Setup
If this is your first time installing Homebrew on your machine, you may need to
add its binaries to your system's `$PATH` environment variable so that you can
use the `brew` command. Try running `brew help` to see if Homebrew is
recognized and runs properly. If that fails, then you'll need to add a
configuration line to wherever your `$PATH` environment variable is set.
Your system `$PATH` environment variable is likely set in one of these
locations:
For BASH shells:
- `~/.bashrc`
- `~/.bash_profile`
- `~/.profile`
For ZSH shells:
- `~/.zshrc`
- `~/.zprofile`
There may be different files that you need to modify for other shell
environments.
Which file you need to modify depends on whether or not you are running an
interactive shell or a login shell
(see [this Stack Overflow post](https://stackoverflow.com/questions/18186929/what-are-the-differences-between-a-login-shell-and-interactive-shell)
for an explanation of the differences). If you're still not sure, please ask
the team for help!
Once you determine which file you'll need to modify, add these lines before any
lines that add or modify the `$PATH` environment variable; near or at the top
of the file is appropriate:
```sh
# Homebrew setup
eval "$(/opt/homebrew/bin/brew shellenv)"
```
This will make sure Homebrew gets setup correctly. Once you make these changes,
either start a new shell session or source the file
(`source ~/.FILE-YOU-MODIFIED`) you modified to have your system recognize the
changes.
Verify that Homebrew is now working by trying to run `brew help` again.
### [API Step] System-Level Package Installation
There are several packages you will need to install for your system in order to
get the app running (and these are good to have in general for any software
development).
Start off with these packages since they're quick and don't require additional
configuration after installation to get working out of the box:
- [jq](https://stedolan.github.io/jq/) - for working with JSON in the command
line
- [git](https://git-scm.com/) - for version control management
- [tfenv](https://github.com/tfutils/tfenv) - for managing
[Terraform](https://www.terraform.io/) installations
- [cf-cli@8](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html) - for
working with a Cloud Foundry platform (e.g., cloud.gov)
- [vim](https://www.vim.org/) - for editing files more easily in the command
line
- [wget](https://www.gnu.org/software/wget/) - for retrieving files in the
command line
You can install them by running the following:
```sh
brew install jq git tfenv cloudfoundry/tap/cf-cli@8 vim wget
```
#### [API Step] Terraform Installation
As a part of the installation above, you just installed `tfenv` to manage
Terraform installations. This is great, but you still need to install Terraform
itself, which can be done with this command:
```sh
tfenv install latest:^1.4.0
```
_NOTE: This project currently uses the latest `1.4.x release of Terraform._
#### [API Step] Python Installation
Now we're going to install a tool to help us manage Python versions and
virtual environments on our system. First, we'll install
[pyenv](https://github.com/pyenv/pyenv) and one of its plugins,
[pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv), with Homebrew:
```sh
brew install pyenv pyenv-virtualenv
```
When these finish installing, you'll need to make another adjustment in the
file that you adjusted for your `$PATH` environment variable and Homebrew's
setup. Open the file, and add these lines to it:
```
# pyenv setup
export PYENV_ROOT="$HOME/.pyenv"
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
```
Once again, start a new shell session or source the file in your current shell
session to make the changes take effect.
Now we're ready to install the Python version we need with `pyenv`, like so:
```sh
pyenv install 3.9
```
This will install the latest version of Python 3.9.
_NOTE: This project currently runs on Python 3.9.x._
#### [API Step] Python Dependency Installation
Lastly, we need to install the tool we use to manage Python dependencies within
the project, which is [poetry](https://python-poetry.org/).
Visit the
[official installer instructions page](https://python-poetry.org/docs/#installing-with-the-official-installer)
and follow the steps to install Poetry directly with the script.
This will ensure `poetry` doesn't conflict with any project virtual environments
and can update itself properly.
#### Node.js and npm Installation
The project will manage most of the Node.js pieces for you, but you will need to
install
[nvm (Node Version Manager)](https://github.com/nvm-sh/nvm#installing-and-updating)
in order for things to work.
Follow the steps in the installation instructions to get `nvm` installed with
the install script they provide, and double check that the file that you
adjusted for your `$PATH` environment variable and Homebrew's setup is properly
updated with the `nvm` setup as well.
Open the file and check for the `nvm` setup lines; if they're not there, then
add the lines found in the
[nvm installation instructions](https://github.com/nvm-sh/nvm#install--update-script)
to to the file after the `pyenv` section you created, e.g.:
```
# nvm setup
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
```
### First-Time Project Setup
Once all of pre-requisites for the project are installed and you have a
cloud.gov account, you can now set up the admin project and get things running
locally!
First, clone the respository in the directory of your choosing on your machine:
```sh
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.9.18` is what is installed on your
machine):
```sh
cd notifications-admin
pyenv virtualenv 3.9.18 notify-admin
pyenv local notify-admin
```
_If you're not sure which version of Python was installed with `pyenv`, you can check by running `pyenv versions` and it'll list everything available currently._
Now [log into cloud.gov](https://cloud.gov/docs/getting-started/setup/#set-up-the-command-line)
in the command line by using this command:
```sh
cf login -a api.fr.cloud.gov --sso
```
_REMINDER: Ensure you have access to the `notify-local-dev` and `notify-staging` spaces in cloud.gov_
Now run the development Terraform setup by navigating to the development
folder and running the script in it:
```sh
cd terraform/development
./run.sh
```
In addition to some infrastructure setup, this will also create a local `.env`
file for you in the project's root directory, which will include a handful of
project-specific environment variables.
#### Updating the .env file for E2E tests
With the newly created `.env` file in place, you'll need to make one more
adjustment in it for things to run properly for the E2E tests.
Open the `.env` file and look for the `NODE_EXTRA_CA_CERTS` environment variable
toward the top. You will need to modify its value to be the path to Homebrew's
CA certificate file.
Run `brew --prefix` to see Homebrew's root directory, and use that value to
update the `NODE_EXTRA_CA_CERTS` environment variable with the path. For
example, if `brew --prefix` gave `/opt/homebrew` as output, then the line would
look like this:
```
NODE_EXTRA_CA_CERTS=/opt/homebrew/etc/ca-certificates/cert.pem
```
Save this change to the `.env` file and you'll be squared away.
## Running the Project and Routine Maintenance
The first time you run the project you'll need to run the project setup from the
root project directory:
```sh
make bootstrap
```
This command is handled by the `Makefile` file in the root project directory, as
are a few others.
_NOTE: You'll want to occasionally run `make bootstrap` to keep your project up-to-date, especially when there are dependency updates._
Now you can run the web server and background workers for asynchronous jobs:
```sh
make run-flask
```
This will run the local development web server and make the admin site
available at http://localhost:6012; remember to make sure that the Notify.gov
API is running as well!
## Git Hooks
We're using [`pre-commit`](https://pre-commit.com/) to manage hooks in order to
automate common tasks or easily-missed cleanup. It's installed as part of
`make bootstrap` and is limited to this project's virtualenv.
To run the hooks in advance of a `git` operation, use
`poetry run pre-commit run`. For running across the whole codebase (useful after
adding a new hook), use `poetry run pre-commit run --all-files`.
The configuration is stored in `.pre-commit-config.yaml`. In that config, there
are links to the repos from which the hooks are pulled, so hop through there if
ou want a detailed description of what each one is doing.
We do not maintain any hooks in this repository.
## Python Dependency Management
We're using [`Poetry`](https://python-poetry.org/) for managing our Python
dependencies and local virtual environments. When it comes to managing the
Python dependencies, there are a couple of things to bear in mind.
For situations where you manually manipulate the `pyproject.toml` file, you
should use the `make py-lock` command to sync the `poetry.lock` file. This will
ensure that you don't inadvertently bring in other transitive dependency updates
that have not been fully tested with the project yet.
If you're just trying to update a dependency to a newer (or the latest) version,
you should let Poetry take care of that for you by running the following:
```sh
poetry update <dependency> [<dependency>...]
```
You can specify more than one dependency together. With this command, Poetry
will do the following for you:
- Find the latest compatible version(s) of the specified dependency/dependencies
- Install the new versions
- Update and sync the `poetry.lock` file
In either situation, once you are finished and have verified the dependency
changes are working, please be sure to commit both the `pyproject.toml` and
`poetry.lock` files.
### Keeping the notification-utils Dependency Up-to-Date
The `notifications-utils` dependency references the other repository we have at
https://github.com/GSA/notifications-utils - this dependency requires a bit of
extra legwork to ensure it stays up-to-date.
Whenever a PR is merged in the `notifications-utils` repository, we need to make
sure the changes are pulled in here and committed to this repository as well.
You can do this by going through these steps:
- Make sure your local `main` branch is up-to-date
- Create a new branch to work in
- Run `make update-utils`
- Commit the updated `poetry.lock` file and push the changes
- Make a new PR with the change
- Have the PR get reviewed and merged
If you are using VS Code, there are also instructions for [running inside Docker](./docs/docker-remote-containers.md)
### Python dependency management
@@ -392,7 +87,7 @@ that have not been fully tested with the project yet.
If you're just trying to update a dependency to a newer (or the latest) version,
you should let Poetry take care of that for you by running the following:
```sh
```
poetry update <dependency> [<dependency>...]
```
@@ -424,21 +119,6 @@ You can do this by going through these steps:
- Make a new PR with the change
- Have the PR get reviewed and merged
## Known Installation Issues
### Python Installation Errors
On M1 Macs, if you get a `fatal error: 'Python.h' file not found` message, try a
different method of installing Python. The recommended approach is to use
[`pyenv`](https://github.com/pyenv/pyenv), as noted above in the installation
instructions.
If you're using PyCharm for Python development, we've noticed some quirkiness
with the IDE and the interaction between Poetry and virtual environment
management that could cause a variety of problems to come up during project
setup and dependency management. Other tools, such as Visual Studio Code, have
proven to be a smoother experience for folks.
## To test the application
From a terminal within the running devcontainer:
@@ -451,13 +131,11 @@ make test
npm run test-watch
```
To run a specific JavaScript test, you'll need to copy the full command from
`package.json`.
To run a specific JavaScript test, you'll need to copy the full command from `package.json`.
## Running a11y-scans locally
Unlike most of the tests and scans, pa11y-ci cannot currently be run from within
the VSCode dev container.
Unlike most of the tests and scans, pa11y-ci cannot currently be run from within the VSCode dev container.
1. `make run-flask` from within the devcontainer
2. Run `make a11y-scan` from your host computer.
@@ -470,31 +148,16 @@ the VSCode dev container.
## License && public domain
Work through
[commit `543be77`](https://github.com/GSA/notifications-admin/commit/543be77776b64fddb6ba70fbb015ecd81a372478)
is licensed by the UK government under the MIT license. Work after that commit
is in the worldwide public domain. See [LICENSE.md](./LICENSE.md) for more
information.
Work through [commit `543be77`](https://github.com/GSA/notifications-admin/commit/543be77776b64fddb6ba70fbb015ecd81a372478) is licensed by the UK government under the MIT license. Work after that commit is in the worldwide public domain. See [LICENSE.md](./LICENSE.md) for more information.
## Contributing
As stated in [CONTRIBUTING.md](CONTRIBUTING.md), all contributions to this
project will be released under the CC0 dedication. By submitting a pull request,
you are agreeing to comply with this waiver of copyright interest.
As stated in [CONTRIBUTING.md](CONTRIBUTING.md), all contributions to this project will be released under the CC0 dedication. By submitting a pull request, you are agreeing to comply with this waiver of copyright interest.
## About the TTS Public Benefits Studio
The Public Benefits Studio is a team inside of
[GSAs Technology Transformation Services](https://www.gsa.gov/about-us/organization/federal-acquisition-service/technology-transformation-services)
(TTS), home to innovative programs like [18F](https://18f.gsa.gov/) and
[Login.gov](https://login.gov). We collaborate with benefits programs to develop
shared technology tools and best practices that reduce the burden of navigating
government programs for low income individuals and families.
The Public Benefits Studio is a team inside of [GSAs Technology Transformation Services](https://www.gsa.gov/about-us/organization/federal-acquisition-service/technology-transformation-services) (TTS), home to innovative programs like [18F](https://18f.gsa.gov/) and [Login.gov](https://login.gov). We collaborate with benefits programs to develop shared technology tools and best practices that reduce the burden of navigating government programs for low income individuals and families.
Were a cross-functional team of technologists with specialized experience
working across public benefits programs like Medicaid, SNAP, and unemployment
insurance.
Were a cross-functional team of technologists with specialized experience working across public benefits programs like Medicaid, SNAP, and unemployment insurance.
For more information on what we're working on, the Notify tool, and how to get
involved with our team,
[see our flyer.](https://github.com/GSA/notifications-admin/blob/main/docs/notify-pilot-flyer.md)
For more information on what we're working on, the Notify tool, and how to get involved with our team, [see our flyer.](https://github.com/GSA/notifications-admin/blob/main/docs/notify-pilot-flyer.md)

View File

@@ -39,7 +39,6 @@ from app.asset_fingerprinter import asset_fingerprinter
from app.config import configs
from app.extensions import redis_client, zendesk_client
from app.formatters import (
convert_markdown_template,
convert_to_boolean,
format_auth_type,
format_billions,
@@ -54,7 +53,6 @@ from app.formatters import (
format_datetime_normal,
format_datetime_relative,
format_datetime_short,
format_datetime_short_america,
format_day_of_week,
format_delta,
format_delta_days,
@@ -90,12 +88,12 @@ from app.navigation import (
HeaderNavigation,
MainNavigation,
OrgNavigation,
SecondaryNavigation,
)
from app.notify_client import InviteTokenError
from app.notify_client.api_key_api_client import api_key_api_client
from app.notify_client.billing_api_client import billing_api_client
from app.notify_client.complaint_api_client import complaint_api_client
from app.notify_client.email_branding_client import email_branding_client
from app.notify_client.events_api_client import events_api_client
from app.notify_client.inbound_number_client import inbound_number_client
from app.notify_client.invite_api_client import invite_api_client
@@ -107,13 +105,18 @@ 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.provider_client import provider_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.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
from app.url_converters import (
SimpleDateTypeConverter,
TemplateTypeConverter,
TicketTypeConverter,
)
login_manager = LoginManager()
csrf = CSRFProtect()
@@ -131,7 +134,6 @@ navigation = {
"main_navigation": MainNavigation(),
"header_navigation": HeaderNavigation(),
"org_navigation": OrgNavigation(),
"secondary_navigation": SecondaryNavigation(),
}
@@ -148,15 +150,8 @@ def _csp(config):
"'unsafe-eval'",
"https://js-agent.newrelic.com",
"https://gov-bam.nr-data.net",
"https://www.googletagmanager.com",
"https://www.google-analytics.com",
"https://dap.digitalgov.gov",
],
"connect-src": [
"'self'",
"https://gov-bam.nr-data.net",
"https://www.google-analytics.com",
],
"connect-src": ["'self'", "https://gov-bam.nr-data.net"],
"style-src": ["'self'", asset_domain],
"img-src": ["'self'", asset_domain, logo_domain],
}
@@ -185,6 +180,7 @@ def create_app(application):
api_key_api_client,
billing_api_client,
complaint_api_client,
email_branding_client,
events_api_client,
inbound_number_client,
invite_api_client,
@@ -194,6 +190,7 @@ def create_app(application):
organizations_client,
performance_dashboard_api_client,
platform_stats_api_client,
provider_client,
service_api_client,
status_api_client,
template_folder_api_client,
@@ -287,7 +284,6 @@ def init_app(application):
@application.context_processor
def _attach_current_global_daily_messages():
global_limit = 0
remaining_global_messages = 0
if current_app:
if request.view_args:
@@ -305,10 +301,7 @@ def init_app(application):
remaining_global_messages = global_limit - global_messages_count.get(
"count"
)
return {
"global_message_limit": global_limit,
"daily_global_messages_remaining": remaining_global_messages,
}
return {"daily_global_messages_remaining": remaining_global_messages}
@application.before_request
def record_start_time():
@@ -326,6 +319,7 @@ def init_app(application):
application.url_map.converters["uuid"].to_python = lambda self, value: value
application.url_map.converters["template_type"] = TemplateTypeConverter
application.url_map.converters["ticket_type"] = TicketTypeConverter
application.url_map.converters["simple_date"] = SimpleDateTypeConverter
@@ -549,7 +543,6 @@ def add_template_filters(application):
format_datetime_24h,
format_datetime_normal,
format_datetime_short,
format_datetime_short_america,
valid_phone_number,
linkable_name,
format_date,
@@ -587,7 +580,6 @@ def add_template_filters(application):
format_mobile_network,
format_yes_no,
square_metres_to_square_miles,
convert_markdown_template,
]:
application.add_template_filter(fn)

View File

@@ -0,0 +1,15 @@
(function (window) {
"use strict";
function hasConsentFor (cookieCategory, consentCookie) {
if (consentCookie === undefined) { consentCookie = window.GOVUK.getConsentCookie(); }
if (consentCookie === null) { return false; }
if (!(cookieCategory in consentCookie)) { return false; }
return consentCookie[cookieCategory];
}
window.GOVUK.hasConsentFor = hasConsentFor;
})(window);

View File

@@ -0,0 +1,104 @@
window.GOVUK = window.GOVUK || {};
window.GOVUK.Modules = window.GOVUK.Modules || {};
(function (Modules) {
function CookieBanner () { }
CookieBanner.clearOldCookies = function (consent) {
var gaCookies = ['_ga', '_gid'];
// clear old cookie set by our previous JS, set on the www domain
if (window.GOVUK.cookie('seen_cookie_message')) {
document.cookie = 'seen_cookie_message=;expires=' + new Date().toGMTString() + ';path=/';
}
if (consent === null) {
for (var i = 0; i < gaCookies.length; i++) {
if (window.GOVUK.cookie(gaCookies[i])) {
// GA cookies are set on the base domain so need the www stripping
var cookieString = gaCookies[i] + '=;expires=' + new Date().toGMTString() + ';domain=' + window.location.hostname.replace(/^www\./, '.') + ';path=/';
document.cookie = cookieString;
}
}
}
};
CookieBanner.prototype.start = function ($module) {
this.$module = $module[0];
this.$module.hideCookieMessage = this.hideCookieMessage.bind(this);
this.$module.showConfirmationMessage = this.showConfirmationMessage.bind(this);
this.$module.setCookieConsent = this.setCookieConsent.bind(this);
this.$module.cookieBanner = document.querySelector('.notify-cookie-banner');
this.$module.cookieBannerConfirmationMessage = this.$module.querySelector('.notify-cookie-banner__confirmation');
this.setupCookieMessage();
};
CookieBanner.prototype.setupCookieMessage = function () {
this.$hideLink = this.$module.querySelector('button[data-hide-cookie-banner]');
if (this.$hideLink) {
this.$hideLink.addEventListener('click', this.$module.hideCookieMessage);
}
this.$acceptCookiesLink = this.$module.querySelector('button[data-accept-cookies=true]');
if (this.$acceptCookiesLink) {
this.$acceptCookiesLink.addEventListener('click', () => this.$module.setCookieConsent(true));
}
this.$rejectCookiesLink = this.$module.querySelector('button[data-accept-cookies=false]');
if (this.$rejectCookiesLink) {
this.$rejectCookiesLink.addEventListener('click', () => this.$module.setCookieConsent(false));
}
this.showCookieMessage();
};
CookieBanner.prototype.showCookieMessage = function () {
// Show the cookie banner if not in the cookie settings page
if (!this.isInCookiesPage()) {
var hasCookiesPolicy = window.GOVUK.cookie('cookies_policy');
if (this.$module && !hasCookiesPolicy) {
this.$module.style.display = 'block';
}
}
};
CookieBanner.prototype.hideCookieMessage = function (event) {
if (this.$module) {
this.$module.style.display = 'none';
}
if (event.target) {
event.preventDefault();
}
};
CookieBanner.prototype.setCookieConsent = function (analyticsConsent) {
window.GOVUK.setConsentCookie({ 'analytics': analyticsConsent });
this.$module.showConfirmationMessage(analyticsConsent);
this.$module.cookieBannerConfirmationMessage.focus();
if (analyticsConsent) { window.GOVUK.initAnalytics(); }
};
CookieBanner.prototype.showConfirmationMessage = function (analyticsConsent) {
var messagePrefix = analyticsConsent ? 'Youve accepted analytics cookies.' : 'You told us not to use analytics cookies.';
this.$cookieBannerMainContent = document.querySelector('.notify-cookie-banner__wrapper');
this.$cookieBannerConfirmationMessage = document.querySelector('.notify-cookie-banner__confirmation-message');
this.$cookieBannerConfirmationMessage.insertAdjacentText('afterbegin', messagePrefix);
this.$cookieBannerMainContent.style.display = 'none';
this.$module.cookieBannerConfirmationMessage.style.display = 'block';
};
CookieBanner.prototype.isInCookiesPage = function () {
return window.location.pathname === '/cookies';
};
Modules.CookieBanner = CookieBanner;
})(window.GOVUK.Modules);

View File

@@ -1,10 +0,0 @@
(function (window) {
"use strict";
// Show the current year
const currentYearElement = document.getElementById("current-year");
if (currentYearElement) {
currentYearElement.textContent = new Date().getFullYear();
}
})(window);

View File

@@ -0,0 +1,163 @@
// used by the cookie banner component
(function (root) {
'use strict';
window.GOVUK = window.GOVUK || {};
var DEFAULT_COOKIE_CONSENT = {
'analytics': false
};
var COOKIE_CATEGORIES = {
'_ga': 'analytics',
'_gid': 'analytics'
};
/*
Cookie methods
==============
Usage:
Setting a cookie:
GOVUK.cookie('hobnob', 'tasty', { days: 30 });
Reading a cookie:
GOVUK.cookie('hobnob');
Deleting a cookie:
GOVUK.cookie('hobnob', null);
*/
window.GOVUK.cookie = function (name, value, options) {
if (typeof value !== 'undefined') {
if (value === false || value === null) {
return window.GOVUK.setCookie(name, '', { days: -1 });
} else {
// Default expiry date of 30 days
if (typeof options === 'undefined') {
options = { days: 30 };
}
return window.GOVUK.setCookie(name, value, options);
}
} else {
return window.GOVUK.getCookie(name);
}
};
window.GOVUK.getConsentCookie = function () {
var consentCookie = window.GOVUK.cookie('cookies_policy');
var consentCookieObj;
if (consentCookie) {
try {
consentCookieObj = JSON.parse(consentCookie);
} catch (err) {
return null;
}
if (typeof consentCookieObj !== 'object' && consentCookieObj !== null) {
consentCookieObj = JSON.parse(consentCookieObj);
}
} else {
return null;
}
return consentCookieObj;
};
window.GOVUK.setConsentCookie = function (options) {
var cookieConsent = window.GOVUK.getConsentCookie();
if (!cookieConsent) {
cookieConsent = JSON.parse(JSON.stringify(DEFAULT_COOKIE_CONSENT));
}
for (var cookieType in options) {
cookieConsent[cookieType] = options[cookieType];
// Delete cookies of that type if consent being set to false
if (!options[cookieType]) {
for (var cookie in COOKIE_CATEGORIES) {
if (COOKIE_CATEGORIES[cookie] === cookieType) {
window.GOVUK.cookie(cookie, null);
if (window.GOVUK.cookie(cookie)) {
document.cookie = cookie + '=;expires=' + new Date() + ';domain=' + window.location.hostname.replace(/^www\./, '.') + ';path=/';
}
}
}
}
}
window.GOVUK.setCookie('cookies_policy', JSON.stringify(cookieConsent), { days: 365 });
};
window.GOVUK.checkConsentCookieCategory = function (cookieName, cookieCategory) {
var currentConsentCookie = window.GOVUK.getConsentCookie();
// If the consent cookie doesn't exist, but the cookie is in our known list, return true
if (!currentConsentCookie && COOKIE_CATEGORIES[cookieName]) {
return true;
}
currentConsentCookie = window.GOVUK.getConsentCookie();
// Sometimes currentConsentCookie is malformed in some of the tests, so we need to handle these
try {
return currentConsentCookie[cookieCategory];
} catch (e) {
console.error(e);
return false;
}
};
window.GOVUK.checkConsentCookie = function (cookieName, cookieValue) {
// If we're setting the consent cookie OR deleting a cookie, allow by default
if (cookieName === 'cookies_policy' || (cookieValue === null || cookieValue === false)) {
return true;
}
if (COOKIE_CATEGORIES[cookieName]) {
var cookieCategory = COOKIE_CATEGORIES[cookieName];
return window.GOVUK.checkConsentCookieCategory(cookieName, cookieCategory);
} else {
// Deny the cookie if it is not known to us
return false;
}
};
window.GOVUK.setCookie = function (name, value, options) {
if (window.GOVUK.checkConsentCookie(name, value)) {
if (typeof options === 'undefined') {
options = {};
}
var cookieString = name + '=' + value + '; path=/';
if (options.days) {
var date = new Date();
date.setTime(date.getTime() + (options.days * 24 * 60 * 60 * 1000));
cookieString = cookieString + '; expires=' + date.toGMTString();
}
if (document.location.protocol === 'https:') {
cookieString = cookieString + '; Secure';
}
document.cookie = cookieString;
}
};
window.GOVUK.getCookie = function (name) {
var nameEQ = name + '=';
var cookies = document.cookie.split(';');
for (var i = 0, len = cookies.length; i < len; i++) {
var cookie = cookies[i];
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1, cookie.length);
}
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(cookie.substring(nameEQ.length));
}
}
return null;
};
}(window));

View File

@@ -1,5 +1,12 @@
window.GOVUK.Frontend.initAll();
var consentData = window.GOVUK.getConsentCookie();
window.GOVUK.Modules.CookieBanner.clearOldCookies(consentData);
if (window.GOVUK.hasConsentFor('analytics', consentData)) {
window.GOVUK.initAnalytics();
}
$(() => $("time.timeago").timeago());
var showHideContent = new GOVUK.ShowHideContent();

View File

@@ -0,0 +1,36 @@
(function (global) {
'use strict';
$ = global.jQuery;
let branding_style = $('.usa-radio input[name="branding_style"]:checked');
if (!branding_style.length) { return; }
branding_style = branding_style.val();
const $paneWrapper = $('<div class="govuk-grid-column-full"></div>');
const $form = $('form');
const previewType = $form.data('previewType');
const $previewPane = $(`<iframe src="/_${previewType}?${buildQueryString(['branding_style', branding_style])}" class="branding-preview" scrolling="no"></iframe>`);
function buildQueryString () {
return $.map(arguments, (val, idx) => encodeURI(val[0]) + '=' + encodeURI(val[1])).join('&');
}
function setPreviewPane (e) {
const $target = $(e.target);
if ($target.attr('name') == 'branding_style') {
branding_style = $target.val();
}
$previewPane.attr('src', `/_${previewType}?${buildQueryString(['branding_style', branding_style])}`);
}
$paneWrapper.append($previewPane);
$form.find('.govuk-grid-row').eq(0).prepend($paneWrapper);
$form.attr('action', location.pathname.replace(new RegExp(`set-${previewType}-branding$`), `preview-${previewType}-branding`));
$form.find('button[type="submit"]').text('Save');
$('fieldset').on('change', 'input[name="branding_style"]', setPreviewPane);
})(window);

View File

@@ -22,26 +22,16 @@ i.e.
@use "uswds-core" as *;
.usa-header--extended {
.usa-logo {
font-family: family("sans");
margin: units(4) 0;
@include at-media-max('mobile-lg') {
margin: units(4) 0 units(4) units(2);
}
img {
@include at-media($theme-header-min-width) {
width: 80px;
height: 70px;
}
}
.usa-header--extended .usa-logo {
font-family: family("sans");
margin: units(4) 0;
@include at-media-max('mobile-lg') {
margin: units(4) 0 units(4) units(2);
}
.usa-nav__secondary {
.usa-nav__link {
padding: 0;
&.usa-current {
text-decoration: underline;
}
img {
@include at-media($theme-header-min-width) {
width: 80px;
height: 70px;
}
}
}
@@ -54,12 +44,15 @@ i.e.
&> .usa-nav__primary-item > a {
font-size: size("body", 4);
}
// &> .usa-nav__primary-item:last-child {
// margin-left: auto;
// @include u-margin-right(-4);
// }
&> .usa-nav__primary-item:last-child {
margin-left: auto;
@include u-margin-right(-4);
}
}
.usa-nav__primary
h1 {
font-weight: bold !important;
}
@@ -121,48 +114,12 @@ td.table-empty-message {
text-decoration: none !important;
}
.button-flex-header {
flex-direction: column;
align-items: flex-start;
margin-bottom: units(2);
@include at-media(desktop) {
flex-direction: row;
display: flex;
align-items: center;
justify-content: space-between;
}
}
.user-list-edit-link:active:before,
.user-list-edit-link:focus:before {
box-shadow: none;
border: 0;
}
.user-list {
display: inline-flex;
flex-wrap: wrap;
gap: units(4);
.user-list-item {
@include at-media(desktop) {
flex: 1 1 calc(50% - units(2));
flex-grow: 0;
}
flex: 1 1 calc(100% - units(2));
border: 1px solid color('gray-cool-10');
padding: units(2);
.tick-cross-list-permissions {
margin: units(1) 0;
padding-left: units(2);
}
.hint {
display: block;
font-size: size("body", "sm");
font-weight: normal;
}
}
}
.template-list-item-without-ancestors .template-list-folder:active,
.template-list-item-without-ancestors .template-list-folder:active::before,
.template-list-item-without-ancestors .template-list-folder:focus,
@@ -175,15 +132,11 @@ td.table-empty-message {
box-shadow: none;
}
.template-list-item-hidden-by-default {
display: none
}
.folder-heading a.folder-heading-folder,
.template-list-folder {
background: url(../img/material-icons/folder.svg) no-repeat;
padding-left: units(4);
display: inline-block;
display: inline-block;
background-position: 0;
}
@@ -213,7 +166,7 @@ td.table-empty-message {
.template-list-template {
background: url(../img/material-icons/description.svg) no-repeat;
padding-left: units(4);
display: inline-flex;
display: inline-flex;
}
.js-enabled .live-search {
@@ -261,7 +214,7 @@ td.table-empty-message {
}
.usa-hero {
background-image: none;
background-image: none;
}
.usa-prose {
@@ -279,7 +232,7 @@ td.table-empty-message {
}
.navigation-service.usa-breadcrumb {
bottom: 0;
-bottom: 0;
}
// Dashboard
@@ -293,13 +246,13 @@ td.table-empty-message {
text-decoration: none;
&:hover{
background: color("blue-warm-70v");
}
}
}
span {
color: white;
color: white;
}
.big-number-smaller {
display: flex;
display: flex;
flex-direction: column;
.big-number-number {
font-size: units(5);
@@ -311,15 +264,15 @@ td.table-empty-message {
}
.big-number-status {
background: color("green-cool-40v");
display: flex;
display: flex;
padding: units(1) units(2);
&--failing {
padding: 0;
padding: 0;
a.usa-link {
color: white;
background: color("red-warm-50v");
padding: units(1) units(2);
margin: 0;
margin: 0;
width: 100%;
&:hover {
background: color("red-warm-60v");
@@ -329,12 +282,12 @@ td.table-empty-message {
}
}
.usa-table {
width: 100%;
width: 100%;
caption {
margin-bottom: 0;
margin-bottom: 0;
}
.table-field-center-aligned {
text-align: center;
text-align: center;
}
.template-statistics-table-template-name {
padding-left: units(4);
@@ -347,89 +300,63 @@ td.table-empty-message {
.dashboard-table {
table {
width: 100%;
width: 100%;
}
.file-list-filename {
font-weight: bold;
}
.file-list-hint {
margin: 0;
margin: 0;
}
.table-field, .table-field-right-aligned {
width: 50%;
width: 50%;
}
&.usage-table {
.table-field, .table-field-left-aligned, .table-field-right-aligned {
width: auto;
width: auto;
}
}
}
.usage-table {
ul {
list-style: none;
padding: 0;
margin: 0;
list-style: none;
padding: 0;
margin: 0;
}
.big-number-smallest {
display: flex;
display: flex;
flex-direction: column;
}
}
.job-table {
.usa-table {
thead th.file-name {
width: 25%;
}
thead th.template {
width: 20%;
}
thead th.time-sent {
width: 30%;
}
thead th.sender {
width: 15%;
}
thead th.\#-of-recipients {
width: 5%;
}
thead th.report {
width: 5%;
}
th {
padding: 0.5rem 0.5rem;
}
}
}
#template-list {
max-height: 500px;
overflow-y: auto;
padding: units(1) 0 units(1) units(1);
margin: units(2) 0 units(5);
ul {
padding: 0;
margin: 0;
list-style: none;
}
padding: 0;
margin: 0;
list-style: none;
}
}
// Tabs
.tabs {
.pill {
display: flex;
display: flex;
list-style: none;
padding: 0;
padding: 0;
.pill-item__container {
border: 1px solid color("gray-cool-10");
flex: 1;
display: flex;
flex-direction: column;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
text-align: center;
font-size: units(2);
a {
a {
padding: units(4);
.big-number-smaller {
font-size: units(5);
@@ -440,7 +367,7 @@ td.table-empty-message {
}
&:not(.pill-item--selected):hover {
background: color("blue-warm-70v");
}
}
&.pill-item--selected:hover {
color: color("blue-60v");
}
@@ -452,12 +379,12 @@ td.table-empty-message {
// Etc
.email-brand, .browse-list {
padding: 0;
margin: 0;
list-style: none;
padding: 0;
margin: 0;
list-style: none;
margin-bottom: units(2);
li {
padding: 8px 0;
padding: 8px 0;
}
}
@@ -465,7 +392,7 @@ details form {
box-sizing:border-box;
}
// Textbox highlight
// Textbox highlight
.textbox-highlight {
@@ -513,7 +440,7 @@ details form {
.placeholder,
.placeholder-conditional {
background-color: #fff;
position: relative;
position: relative;
&:after {
content: "";
background-color: color("yellow-20v");
@@ -522,9 +449,9 @@ details form {
position: absolute;
top: 0;
left: 5px;
right: 6px;
right: 6px;
height: 100%;
border-radius: 7px;
border-radius: 7px;
}
}
@@ -544,7 +471,3 @@ details form {
padding-left: 5px;
letter-spacing: 0.04em;
}
.edit-textbox-error-mt {
margin-top: 1.5rem;
}

View File

@@ -1,43 +0,0 @@
# Get started
1. ## Check if Notify.gov is right for you
<p>Read about our <a class="usa-link" href="{{ url_for('main.features') }}">features</a>, <a class="usa-link" href="{{ url_for('.pricing') }}">pricing</a> and <a class="usa-link" href="{{ url_for('main.roadmap') }}">roadmap</a>.</p>
2. ## Create an account
{% if not current_user.is_authenticated %}
<p><a class="usa-link" href="{{ url_for('.register') }}">Create an account</a> for free and add your first Notify service. When you add a new service it will start in <a class="usa-link" href="{{ url_for('main.trial_mode_new') }}">trial mode</a>.</p>
{% else %}
<p>Create an account for free and add your first Notify service. When you add a new service, it will start in <a class="usa-link" href="{{ url_for('main.trial_mode_new') }}">trial mode</a>.</p>
{% endif %}
3. ## Write some messages
{% if True %}
<p>Add message templates with examples of the content you plan to send. You can use our <a class="usa-link" href="{{ url_for('main.guidance_index') }}">guidance</a> to help you.</p>
{% endif %}
4. ## Set up your service
{% if not current_user.is_authenticated or not current_service %}
<p>Review your settings to add message branding and sender information.</p>
<p>Add team members and check their permissions.</p>
{% else %}
<p>Review your <a class="usa-link" href="{{ url_for('.service_settings', service_id=current_service.id) }}">settings</a> to add message branding and sender information.</p>
<p>Add <a class="usa-link" href="{{ url_for('.manage_users', service_id=current_service.id) }}">team members</a> and check their permissions.</p>
{% endif %}
5. ## Start sending messages
{% if not current_user.is_authenticated or not current_service %}
<p>When youre ready to send messages to people outside your team, go to the <b class="bold">Settings</b> page and select <b class="bold">Request to go live</b>. Well approve your request within one working day.</p>
{% else %}
<p>You should <a class="usa-link" href="{{ url_for('.support') }}">request to go live</a> when youre ready to send messages to people outside your team. Well approve your request within one working day.</p>
{% endif %}
<!-- <li class="get-started-list__item">
<h2 class="usa-process-list__heading line-height-sans-1">Set up an API integration (optional)</h2>
<p>You can use the Notify API to send messages automatically.</p>
<p>Our <a class="usa-link" href="{{ url_for('main.documentation') }}">documentation</a> explains how to integrate the API with a web application or back office system.</p>
</li> -->
<!-- <p>Check <a class="usa-link" href="{{ url_for('main.how_to_pay') }}">how to pay</a> if youre planning to exceed the <a class="usa-link" href="{{ url_for('.pricing', _anchor='text-messages') }}">free text message allowance</a>.</p> -->

View File

@@ -24,7 +24,6 @@ EVENT_SCHEMAS = {
"service_id",
"ui_permissions",
},
"resend_user_invite_to_service": {"email_address", "resent_by_id", "service_id"},
"cancel_user_invite_to_service": {"email_address", "canceled_by_id", "service_id"},
"set_user_permissions": {
"user_id",
@@ -64,10 +63,6 @@ def create_cancel_user_invite_to_service_event(**kwargs):
_send_event("cancel_user_invite_to_service", **kwargs)
def create_resend_user_invite_to_service_event(**kwargs):
_send_event("resend_user_invite_to_service", **kwargs)
def create_add_user_to_service_event(**kwargs):
_send_event("add_user_to_service", **kwargs)

View File

@@ -1,4 +1,3 @@
import os
import re
import unicodedata
import urllib
@@ -10,56 +9,17 @@ from numbers import Number
import ago
import dateutil
import humanize
import markdown
import pytz
from bs4 import BeautifulSoup
from flask import Markup, render_template_string, url_for
from flask.helpers import get_root_path
from flask import Markup, url_for
from notifications_utils.field import Field
from notifications_utils.formatters import make_quotes_smart
from notifications_utils.formatters import nl2br as utils_nl2br
from notifications_utils.recipients import InvalidPhoneError, validate_phone_number
from notifications_utils.take import Take
from app.utils.csv import get_user_preferred_timezone
from app.utils.time import parse_naive_dt
def apply_html_class(tags, html_file):
new_html = html_file
for tag in tags:
element = tag[0]
class_name = tag[1]
soup = BeautifulSoup(new_html, "html.parser")
for xtag in soup.find_all(element):
xtag["class"] = class_name
new_html = str(soup)
return new_html
def convert_markdown_template(mdf, test=False):
content_text = ""
if not test:
APP_ROOT = get_root_path("notifications-admin")
file = "app/content/" + mdf + ".md"
md_file = os.path.join(APP_ROOT, file)
with open(md_file) as f:
content_text = f.read()
else:
content_text = mdf
jn_render = render_template_string(content_text)
md_render = markdown.markdown(jn_render)
return md_render
def convert_to_boolean(value):
if isinstance(value, str):
if value.lower() in ["t", "true", "on", "yes", "1"]:
@@ -71,14 +31,13 @@ def convert_to_boolean(value):
def format_datetime(date):
return "{} at {} {}".format(
format_date(date), format_time_24h(date), get_user_preferred_timezone()
)
return "{} at {} UTC".format(format_date(date), format_time_24h(date))
def format_datetime_24h(date):
return "{} at {} {}".format(
format_date(date), format_time_24h(date), get_user_preferred_timezone()
return "{} at {} UTC".format(
format_date(date),
format_time_24h(date),
)
@@ -87,74 +46,39 @@ def format_time(date):
def format_datetime_normal(date):
return "{} at {} {}".format(
format_date_normal(date), format_time_24h(date), get_user_preferred_timezone()
)
return "{} at {} UTC".format(format_date_normal(date), format_time_24h(date))
def format_datetime_short(date):
return "{} at {} {}".format(
format_date_short(date), format_time_24h(date), get_user_preferred_timezone()
)
def format_datetime_short_america(date):
return "{} at {}".format(format_date_numeric_america(date), format_time_12h(date))
def format_date_numeric_america(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%m-%d-%Y")
)
def format_time_12h(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%I:%M %p")
)
return "{} at {} UTC".format(format_date_short(date), format_time_24h(date))
def format_datetime_relative(date):
return "{} at {} {}".format(
get_human_day(date), format_time_24h(date), get_user_preferred_timezone()
)
return "{} at {} UTC".format(get_human_day(date), format_time_24h(date))
def format_datetime_numeric(date):
return "{} {} {}".format(
format_date_numeric(date), format_time_24h(date), get_user_preferred_timezone()
return "{} {} UTC".format(
format_date_numeric(date),
format_time_24h(date),
)
def format_date_numeric(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%Y-%m-%d")
)
return date.strftime("%Y-%m-%d")
def format_time_24h(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%H:%M")
return date.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())
time = time.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
date = (time - timedelta(minutes=1)).date()
now = datetime.now(preferred_tz)
now = datetime.now(pytz.utc)
if date == (now + timedelta(days=1)).date():
return "tomorrow"
@@ -176,12 +100,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())
return (
date.replace(tzinfo=timezone.utc)
.astimezone(preferred_tz)
.strftime("%A %d %B %Y")
)
return date.strftime("%A %d %B %Y")
def format_date_normal(date):
@@ -191,10 +110,7 @@ def format_date_normal(date):
def format_date_short(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return _format_datetime_short(
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
)
return _format_datetime_short(date)
def format_date_human(date):
@@ -202,17 +118,15 @@ def format_date_human(date):
def format_datetime_human(date, date_prefix=""):
return "{} at {} {}".format(
return "{} at {} UTC".format(
get_human_day(date, date_prefix="on"),
format_time_24h(date),
get_user_preferred_timezone(),
)
def format_day_of_week(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%A")
return date.strftime("%A")
def _format_datetime_short(datetime):
@@ -470,12 +384,6 @@ def message_count_noun(count, template_type):
else:
return "text messages"
if template_type == "parts":
if count == 1:
return "text message part"
else:
return "text message parts"
elif template_type == "email":
if count == 1:
return "email"

View File

@@ -9,6 +9,7 @@ from app.main.views import ( # noqa isort:skip
code_not_received,
conversation,
dashboard,
email_branding,
feedback,
find_services,
find_users,
@@ -25,6 +26,7 @@ from app.main.views import ( # noqa isort:skip
performance,
platform_admin,
pricing,
providers,
register,
security_policy,
send,

View File

@@ -61,20 +61,18 @@ from app.main.validators import (
ValidEmail,
ValidGovEmail,
)
from app.models.feedback import PROBLEM_TICKET_TYPE, QUESTION_TICKET_TYPE
from app.models.organization import Organization
from app.utils import merge_jsonlike
from app.utils.csv import get_user_preferred_timezone
from app.utils import branding, merge_jsonlike
from app.utils.user_permissions import all_ui_permissions, permission_options
def get_time_value_and_label(future_time):
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
future_time.astimezone(preferred_tz).replace(tzinfo=None).isoformat(),
"{} at {} {}".format(
get_human_day(future_time.astimezone(preferred_tz)),
get_human_time(future_time.astimezone(preferred_tz)),
get_user_preferred_timezone(),
future_time.astimezone(pytz.utc).replace(tzinfo=None).isoformat(),
"{} at {} UTC".format(
get_human_day(future_time.astimezone(pytz.utc)),
get_human_time(future_time.astimezone(pytz.utc)),
),
)
@@ -87,25 +85,21 @@ 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())
time = (time - timedelta(hours=1)).strftime("%A")
if time == datetime.now(preferred_tz).strftime("%A"):
if time == datetime.now(pytz.utc).strftime("%A"):
return "{}oday".format(prefix_today_with)
if time == (datetime.now(preferred_tz) + timedelta(days=1)).strftime("%A"):
if time == (datetime.now(pytz.utc) + timedelta(days=1)).strftime("%A"):
return "Tomorrow"
return time
def get_furthest_possible_scheduled_time():
# We want local time to find date boundaries
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (datetime.now(preferred_tz) + timedelta(days=4)).replace(hour=0)
return (datetime.now(pytz.utc) + timedelta(days=4)).replace(hour=0)
def get_next_hours_until(until):
preferred_tz = pytz.timezone(get_user_preferred_timezone())
now = datetime.now(preferred_tz)
now = datetime.now(pytz.utc)
hours = int((until - now).total_seconds() / (60 * 60))
return [
(now + timedelta(hours=i)).replace(minute=0, second=0, microsecond=0)
@@ -114,8 +108,7 @@ def get_next_hours_until(until):
def get_next_days_until(until):
preferred_tz = pytz.timezone(get_user_preferred_timezone())
now = datetime.now(preferred_tz)
now = datetime.now(pytz.utc)
days = int((until - now).total_seconds() / (60 * 60 * 24))
return [
get_human_day((now + timedelta(days=i)), prefix_today_with="Later t")
@@ -814,6 +807,49 @@ class GovukCheckboxField(BooleanField):
)
class GovukTextareaField(TextAreaField):
def __init__(self, label="", validators=None, param_extensions=None, **kwargs):
super(TextAreaField, self).__init__(label, validators, **kwargs)
self.param_extensions = param_extensions
# self.__call__ renders the HTML for the field by:
# 1. delegating to self.meta.render_field which
# 2. calls field.widget
# this bypasses that by making self.widget a method with the same interface as widget.__call__
def widget(self, field, param_extensions=None, **kwargs):
# error messages
error_message = None
if field.errors:
error_message = {"text": field.errors[0]}
params = {
"name": field.name,
"id": field.id,
"rows": 8,
"label": {
"text": field.label.text,
"classes": None,
"isPageHeading": False,
},
"hint": {"text": None},
"errorMessage": error_message,
}
# extend default params with any sent in during instantiation
if self.param_extensions:
merge_jsonlike(params, self.param_extensions)
# add any sent in though use in templates
if param_extensions:
merge_jsonlike(params, param_extensions)
return Markup(
render_template(
"components/components/textarea/template.njk", params=params
)
)
# based on work done by @richardjpope: https://github.com/richardjpope/recourse/blob/master/recourse/forms.py#L6
class GovukCheckboxesField(SelectMultipleField):
render_as_list = False
@@ -912,9 +948,9 @@ class OnOffField(GovukRadiosField):
]
super().__init__(
label,
*args,
choices=choices,
thing=f"{choices[0][1].lower()} or {choices[1][1].lower()}",
*args,
**kwargs,
)
@@ -1152,7 +1188,7 @@ class AdminServiceSMSAllowanceForm(StripWhitespaceForm):
class AdminServiceMessageLimitForm(StripWhitespaceForm):
message_limit = GovukIntegerField(
"Max number of messages the service has per send",
"Number of messages the service is allowed to send each day",
validators=[DataRequired(message="Cannot be empty")],
)
@@ -1235,28 +1271,6 @@ class ChangeNameForm(StripWhitespaceForm):
new_name = GovukTextInputField("Your name")
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
class ChangePreferredTimezoneForm(StripWhitespaceForm):
def __init__(self, *args, **kwargs):
super(ChangePreferredTimezoneForm, self).__init__(*args, **kwargs)
self.new_preferred_timezone.choices = [
("America/Puerto_Rico", "America/Puerto_Rico"),
("US/Eastern", "US/Eastern"),
("US/Central", "US/Central"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Alaska", "US/Alaska"),
("US/Hawaii", "US/Hawaii"),
("US/Aleutian", "US/Aleutian"),
("US/Samoa", "US/Samoa"),
]
new_preferred_timezone = GovukRadiosField(
"What timezone would you like to use?",
default="US/Eastern",
)
class ChangeEmailForm(StripWhitespaceForm):
def __init__(self, validate_email_func, *args, **kwargs):
self.validate_email_func = validate_email_func
@@ -1323,6 +1337,49 @@ class CreateKeyForm(StripWhitespaceForm):
raise ValidationError("A key with this name already exists")
class SupportType(StripWhitespaceForm):
support_type = GovukRadiosField(
"How can we help you?",
choices=[
(PROBLEM_TICKET_TYPE, "Report a problem"),
(QUESTION_TICKET_TYPE, "Ask a question or give feedback"),
],
)
class SupportRedirect(StripWhitespaceForm):
who = GovukRadiosField(
"What do you need help with?",
choices=[
(
"public-sector",
"I work in the public sector and need to send emails or text messages",
),
("public", "Im a member of the public with a question for the government"),
],
param_extensions={"fieldset": {"legend": {"classes": "usa-sr-only"}}},
)
class FeedbackOrProblem(StripWhitespaceForm):
name = GovukTextInputField("Name (optional)")
email_address = email_address(label="Email address", gov_user=False, required=True)
feedback = TextAreaField(
"Your message", validators=[DataRequired(message="Cannot be empty")]
)
class Triage(StripWhitespaceForm):
severe = GovukRadiosField(
"Is it an emergency?",
choices=[
("yes", "Yes"),
("no", "No"),
],
thing="yes or no",
)
class EstimateUsageForm(StripWhitespaceForm):
volume_email = ForgivingIntegerField(
"How many emails do you expect to send in the next year?",
@@ -1358,6 +1415,52 @@ class EstimateUsageForm(StripWhitespaceForm):
return super().validate(*args, **kwargs)
class AdminProviderRatioForm(Form):
def __init__(self, providers):
self._providers = providers
# hack: https://github.com/wtforms/wtforms/issues/736
self._unbound_fields = [
(
provider["identifier"],
GovukIntegerField(
f"{provider['display_name']} (%)",
validators=[
validators.NumberRange(
min=0, max=100, message="Must be between 0 and 100"
)
],
param_extensions={
"classes": "width-8",
},
),
)
for provider in providers
]
super().__init__(
data={
provider["identifier"]: provider["priority"] for provider in providers
}
)
def validate(self, extra_validators=None):
if not super().validate(extra_validators):
return False
total = sum(
getattr(self, provider["identifier"]).data for provider in self._providers
)
if total == 100:
return True
for provider in self._providers:
getattr(self, provider["identifier"]).errors += ["Must add up to 100%"]
return False
class ServiceContactDetailsForm(StripWhitespaceForm):
contact_details_type = RadioField(
"Type of contact details",
@@ -1461,6 +1564,65 @@ class ServiceSwitchChannelForm(ServiceOnOffSettingForm):
super().__init__(name, *args, **kwargs)
class AdminSetEmailBrandingForm(StripWhitespaceForm):
branding_style = GovukRadiosFieldWithNoneOption(
"Branding style",
param_extensions={"fieldset": {"legend": {"classes": "usa-sr-only"}}},
thing="a branding style",
)
DEFAULT = (FieldWithNoneOption.NONE_OPTION_VALUE, "GOV.UK")
def __init__(self, all_branding_options, current_branding):
super().__init__(branding_style=current_branding)
self.branding_style.choices = sorted(
all_branding_options + [self.DEFAULT],
key=lambda branding: (
branding[0] != current_branding,
branding[0] is not self.DEFAULT[0],
branding[1].lower(),
),
)
class AdminPreviewBrandingForm(StripWhitespaceForm):
branding_style = HiddenFieldWithNoneOption("branding_style")
class AdminEditEmailBrandingForm(StripWhitespaceForm):
name = GovukTextInputField("Name of brand")
text = GovukTextInputField("Text")
colour = GovukTextInputField(
"Colour",
validators=[
Regexp(
regex="^$|^#(?:[0-9a-fA-F]{3}){1,2}$",
message="Must be a valid color hex code (starting with #)",
)
],
param_extensions={
"attributes": {"data-module": "colour-preview"},
},
)
file = FileField_wtf(
"Upload a PNG logo", validators=[FileAllowed(["png"], "PNG Images only!")]
)
brand_type = GovukRadiosField(
"Brand type",
choices=[
("both", "GOV.UK and branding"),
("org", "Branding only"),
("org_banner", "Branding banner"),
],
)
def validate_name(self, name):
op = request.form.get("operation")
if op == "email-branding-details" and not self.name.data:
raise ValidationError("This field is required")
class SVGFileUpload(StripWhitespaceForm):
file = FileField_wtf(
"Upload an SVG logo",
@@ -1675,6 +1837,42 @@ class AdminSetOrganizationForm(StripWhitespaceForm):
)
class ChooseBrandingForm(StripWhitespaceForm):
FALLBACK_OPTION_VALUE = "something_else"
FALLBACK_OPTION = (FALLBACK_OPTION_VALUE, "Something else")
@property
def something_else_is_only_option(self):
return self.options.choices == (self.FALLBACK_OPTION,)
class ChooseEmailBrandingForm(ChooseBrandingForm):
options = RadioField("Choose your new email branding")
def __init__(self, service):
super().__init__()
self.options.choices = tuple(
list(branding.get_email_choices(service)) + [self.FALLBACK_OPTION]
)
class SomethingElseBrandingForm(StripWhitespaceForm):
something_else = GovukTextareaField(
"Describe the branding you want",
validators=[DataRequired("Cannot be empty")],
param_extensions={
"label": {
"isPageHeading": True,
"classes": "font-body-xl",
},
"hint": {
"text": "Include links to your brand guidelines or examples of how to use your branding."
},
},
)
class AdminServiceAddDataRetentionForm(StripWhitespaceForm):
notification_type = GovukRadiosField(
"What notification type?",
@@ -1866,6 +2064,13 @@ class AdminClearCacheForm(StripWhitespaceForm):
raise ValidationError("Select at least one option")
class AdminOrganizationGoLiveNotesForm(StripWhitespaceForm):
request_to_go_live_notes = TextAreaField(
"Go live notes",
filters=[lambda x: x or None],
)
class ChangeSecurityKeyNameForm(StripWhitespaceForm):
security_key_name = GovukTextInputField(
"Name of key",

View File

@@ -41,8 +41,8 @@ def _create_example_template(service_id):
@main.route("/add-service", methods=["GET", "POST"])
@user_is_gov_user
@user_is_logged_in
@user_is_gov_user
def add_service():
default_organization_type = current_user.default_organization_type
if default_organization_type is None:

View File

@@ -11,12 +11,10 @@ from werkzeug.utils import redirect
from app import (
billing_api_client,
current_service,
job_api_client,
notification_api_client,
service_api_client,
template_statistics_client,
)
from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left
from app.formatters import format_date_numeric, format_datetime_numeric
from app.main import main
from app.statistics_utils import get_formatted_percentage
from app.utils import (
@@ -47,32 +45,10 @@ def service_dashboard(service_id):
if not current_user.has_permissions("view_activity"):
return redirect(url_for("main.choose_template", service_id=service_id))
notifications = notification_api_client.get_notifications_for_service(
service_id=service_id,
)["notifications"]
job_response = job_api_client.get_jobs(service_id)
service_data_retention_days = 7
jobs = [
{
"job_id": job["id"],
"time_left": get_time_left(job["created_at"]),
"download_link": url_for(
".view_job_csv", service_id=current_service.id, job_id=job["id"]
),
"notification_count": job["notification_count"],
"created_by": job["created_by"],
}
for job in job_response["data"]
]
return render_template(
"views/dashboard/dashboard.html",
updates_url=url_for(".service_dashboard_updates", service_id=service_id),
partials=get_dashboard_partials(service_id),
notifications=notifications,
jobs=jobs,
service_data_retention_days=service_data_retention_days,
)

View File

@@ -0,0 +1,133 @@
from flask import current_app, redirect, render_template, session, url_for
from app import email_branding_client
from app.main import main
from app.main.forms import AdminEditEmailBrandingForm, SearchByNameForm
from app.s3_client.s3_logo_client import (
TEMP_TAG,
delete_email_temp_file,
delete_email_temp_files_created_by,
permanent_email_logo_name,
persist_logo,
upload_email_logo,
)
from app.utils.user import user_is_platform_admin
@main.route("/email-branding", methods=["GET", "POST"])
@user_is_platform_admin
def email_branding():
brandings = email_branding_client.get_all_email_branding(sort_key="name")
return render_template(
"views/email-branding/select-branding.html",
email_brandings=brandings,
search_form=SearchByNameForm(),
)
@main.route("/email-branding/<uuid:branding_id>/edit", methods=["GET", "POST"])
@main.route("/email-branding/<uuid:branding_id>/edit/<logo>", methods=["GET", "POST"])
@user_is_platform_admin
def update_email_branding(branding_id, logo=None):
email_branding = email_branding_client.get_email_branding(branding_id)[
"email_branding"
]
form = AdminEditEmailBrandingForm(
name=email_branding["name"],
text=email_branding["text"],
colour=email_branding["colour"],
brand_type=email_branding["brand_type"],
)
logo = logo if logo else email_branding.get("logo") if email_branding else None
if form.validate_on_submit():
if form.file.data:
upload_filename = upload_email_logo(
form.file.data.filename, form.file.data, user_id=session["user_id"]
)
if logo and logo.startswith(TEMP_TAG.format(user_id=session["user_id"])):
delete_email_temp_file(logo)
return redirect(
url_for(
".update_email_branding",
branding_id=branding_id,
logo=upload_filename,
)
)
updated_logo_name = (
permanent_email_logo_name(logo, session["user_id"]) if logo else None
)
email_branding_client.update_email_branding(
branding_id=branding_id,
logo=updated_logo_name,
name=form.name.data,
text=form.text.data,
colour=form.colour.data,
brand_type=form.brand_type.data,
)
if logo:
persist_logo(logo, updated_logo_name)
delete_email_temp_files_created_by(session["user_id"])
return redirect(url_for(".email_branding", branding_id=branding_id))
return render_template(
"views/email-branding/manage-branding.html",
form=form,
email_branding=email_branding,
cdn_url=current_app.config["LOGO_CDN_DOMAIN"],
logo=logo,
)
@main.route("/email-branding/create", methods=["GET", "POST"])
@main.route("/email-branding/create/<logo>", methods=["GET", "POST"])
@user_is_platform_admin
def create_email_branding(logo=None):
form = AdminEditEmailBrandingForm(brand_type="org")
if form.validate_on_submit():
if form.file.data:
upload_filename = upload_email_logo(
form.file.data.filename, form.file.data, user_id=session["user_id"]
)
if logo and logo.startswith(TEMP_TAG.format(user_id=session["user_id"])):
delete_email_temp_file(logo)
return redirect(url_for(".create_email_branding", logo=upload_filename))
updated_logo_name = (
permanent_email_logo_name(logo, session["user_id"]) if logo else None
)
email_branding_client.create_email_branding(
logo=updated_logo_name,
name=form.name.data,
text=form.text.data,
colour=form.colour.data,
brand_type=form.brand_type.data,
)
if logo:
persist_logo(logo, updated_logo_name)
delete_email_temp_files_created_by(session["user_id"])
return redirect(url_for(".email_branding"))
return render_template(
"views/email-branding/manage-branding.html",
form=form,
cdn_url=current_app.config["LOGO_CDN_DOMAIN"],
logo=logo,
)

View File

@@ -1,10 +1,239 @@
from flask import render_template
from datetime import datetime
import pytz
from flask import redirect, render_template, request, session, url_for
from flask_login import current_user
from govuk_bank_holidays.bank_holidays import BankHolidays
from notifications_utils.clients.zendesk.zendesk_client import NotifySupportTicket
from app import convert_to_boolean, current_service
from app.extensions import zendesk_client
from app.main import main
from app.utils.user import user_is_logged_in
from app.main.forms import FeedbackOrProblem, SupportRedirect, SupportType, Triage
from app.models.feedback import (
GENERAL_TICKET_TYPE,
PROBLEM_TICKET_TYPE,
QUESTION_TICKET_TYPE,
)
from app.utils import hide_from_search_engines
bank_holidays = BankHolidays(use_cached_holidays=True)
@main.route("/support", methods=["GET"])
@user_is_logged_in
@main.route("/support", methods=["GET", "POST"])
@hide_from_search_engines
def support():
return render_template("views/support/index.html")
if current_user.is_authenticated:
form = SupportType()
if form.validate_on_submit():
return redirect(
url_for(
".feedback",
ticket_type=form.support_type.data,
)
)
else:
form = SupportRedirect()
if form.validate_on_submit():
if form.who.data == "public":
return redirect(url_for(".support_public"))
else:
return redirect(
url_for(
".feedback",
ticket_type=GENERAL_TICKET_TYPE,
)
)
return render_template("views/support/index.html", form=form)
@main.route("/support/public")
@hide_from_search_engines
def support_public():
return render_template("views/support/public.html")
@main.route("/support/triage", methods=["GET", "POST"])
@main.route("/support/triage/<ticket_type:ticket_type>", methods=["GET", "POST"])
@hide_from_search_engines
def triage(ticket_type=PROBLEM_TICKET_TYPE):
form = Triage()
if form.validate_on_submit():
return redirect(
url_for(".feedback", ticket_type=ticket_type, severe=form.severe.data)
)
return render_template(
"views/support/triage.html",
form=form,
page_title={
PROBLEM_TICKET_TYPE: "Report a problem",
GENERAL_TICKET_TYPE: "Contact Notify.gov support",
}.get(ticket_type),
)
@main.route("/support/<ticket_type:ticket_type>", methods=["GET", "POST"])
@hide_from_search_engines
def feedback(ticket_type):
form = FeedbackOrProblem()
if not form.feedback.data:
form.feedback.data = session.pop("feedback_message", "")
if request.args.get("severe") in ["yes", "no"]:
severe = convert_to_boolean(request.args.get("severe"))
else:
severe = None
out_of_hours_emergency = all(
(
ticket_type != QUESTION_TICKET_TYPE,
not in_business_hours(),
severe,
)
)
if needs_triage(ticket_type, severe):
session["feedback_message"] = form.feedback.data
return redirect(url_for(".triage", ticket_type=ticket_type))
if needs_escalation(ticket_type, severe):
return redirect(url_for(".bat_phone"))
if current_user.is_authenticated:
form.email_address.data = current_user.email_address
form.name.data = current_user.name
if form.validate_on_submit():
user_email = form.email_address.data
user_name = form.name.data or None
feedback_msg = render_template(
"support-tickets/support-ticket.txt",
content=form.feedback.data,
)
ticket = NotifySupportTicket(
subject="Notify feedback",
message=feedback_msg,
ticket_type=get_zendesk_ticket_type(ticket_type),
p1=out_of_hours_emergency,
user_name=user_name,
user_email=user_email,
org_id=current_service.organization_id if current_service else None,
org_type=current_service.organization_type if current_service else None,
service_id=current_service.id if current_service else None,
)
zendesk_client.send_ticket_to_zendesk(ticket)
return redirect(
url_for(
".thanks",
out_of_hours_emergency=out_of_hours_emergency,
email_address_provided=(
current_user.is_authenticated or bool(form.email_address.data)
),
)
)
return render_template(
"views/support/form.html",
form=form,
back_link=(
url_for(".support")
if severe is None
else url_for(".triage", ticket_type=ticket_type)
),
show_status_page_banner=(ticket_type == PROBLEM_TICKET_TYPE),
page_title={
GENERAL_TICKET_TYPE: "Contact Notify.gov support",
PROBLEM_TICKET_TYPE: "Report a problem",
QUESTION_TICKET_TYPE: "Ask a question or give feedback",
}.get(ticket_type),
)
@main.route("/support/escalate", methods=["GET", "POST"])
@hide_from_search_engines
def bat_phone():
if current_user.is_authenticated:
return redirect(url_for("main.feedback", ticket_type=PROBLEM_TICKET_TYPE))
return render_template("views/support/bat-phone.html")
@main.route("/support/thanks", methods=["GET", "POST"])
@hide_from_search_engines
def thanks():
return render_template(
"views/support/thanks.html",
out_of_hours_emergency=convert_to_boolean(
request.args.get("out_of_hours_emergency")
),
email_address_provided=convert_to_boolean(
request.args.get("email_address_provided")
),
out_of_hours=not in_business_hours(),
)
def in_business_hours():
now = datetime.utcnow().replace(tzinfo=pytz.utc)
if is_weekend(now) or is_bank_holiday(now):
return False
return london_time_today_as_utc(9, 30) <= now < london_time_today_as_utc(17, 30)
def london_time_today_as_utc(hour, minute):
return (
pytz.timezone("Europe/London")
.localize(datetime.now().replace(hour=hour, minute=minute))
.astimezone(pytz.utc)
)
def is_weekend(time):
return time.strftime("%A") in {
"Saturday",
"Sunday",
}
def is_bank_holiday(time):
return bank_holidays.is_holiday(time.date())
def needs_triage(ticket_type, severe):
return all(
(
ticket_type != QUESTION_TICKET_TYPE,
severe is None,
(not current_user.is_authenticated or current_user.live_services),
not in_business_hours(),
)
)
def needs_escalation(ticket_type, severe):
return all(
(
ticket_type != QUESTION_TICKET_TYPE,
severe,
not current_user.is_authenticated,
not in_business_hours(),
)
)
def get_zendesk_ticket_type(ticket_type):
# Zendesk has 4 ticket types - "problem", "incident", "task" and "question".
# We don't want to use a Zendesk "problem" ticket type when someone reports a
# Notify problem because they are designed to group multiple incident tickets together,
# allowing them to be solved as a group.
if ticket_type == PROBLEM_TICKET_TYPE:
return NotifySupportTicket.TYPE_INCIDENT
return NotifySupportTicket.TYPE_QUESTION

View File

@@ -1,9 +1,18 @@
from flask import abort, redirect, render_template, request, url_for
from flask import (
abort,
current_app,
make_response,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user
from notifications_utils.template import HTMLEmailTemplate
from app import status_api_client
from app.formatters import apply_html_class, convert_markdown_template
from app import email_branding_client, status_api_client
from app.main import main
from app.main.forms import FieldWithNoneOption
from app.main.views.pricing import CURRENT_SMS_RATE
from app.main.views.sub_navigation_dictionaries import features_nav, using_notify_nav
from app.utils.user import user_is_logged_in
@@ -54,6 +63,105 @@ def design_content():
)
@main.route("/_email")
@user_is_logged_in
def email_template():
branding_type = "govuk"
branding_style = request.args.get("branding_style", None)
if branding_style == FieldWithNoneOption.NONE_OPTION_VALUE:
branding_style = None
if branding_style is not None:
email_branding = email_branding_client.get_email_branding(branding_style)[
"email_branding"
]
branding_type = email_branding["brand_type"]
if branding_type == "govuk":
brand_text = None
brand_colour = None
brand_logo = None
govuk_banner = True
brand_banner = False
brand_name = None
else:
colour = email_branding["colour"]
brand_text = email_branding["text"]
brand_colour = colour
brand_logo = (
f"https://{current_app.config['LOGO_CDN_DOMAIN']}/{email_branding['logo']}"
if email_branding["logo"]
else None
)
govuk_banner = branding_type in ["govuk", "both"]
brand_banner = branding_type == "org_banner"
brand_name = email_branding["name"]
template = {
"template_type": "email",
"subject": "Email branding preview",
"content": (
"Lorem Ipsum is simply dummy text of the printing and typesetting "
"industry.\n\nLorem Ipsum has been the industrys standard dummy "
"text ever since the 1500s, when an unknown printer took a galley "
"of type and scrambled it to make a type specimen book. "
"\n\n"
"# History"
"\n\n"
"It has "
"survived not only"
"\n\n"
"* five centuries"
"\n"
"* but also the leap into electronic typesetting"
"\n\n"
"It was "
"popularised in the 1960s with the release of Letraset sheets "
"containing Lorem Ipsum passages, and more recently with desktop "
"publishing software like Aldus PageMaker including versions of "
"Lorem Ipsum."
"\n\n"
"^ It is a long established fact that a reader will be distracted "
"by the readable content of a page when looking at its layout."
"\n\n"
"The point of using Lorem Ipsum is that it has a more-or-less "
"normal distribution of letters, as opposed to using Content "
"here, content here, making it look like readable English."
"\n\n\n"
"1. One"
"\n"
"2. Two"
"\n"
"10. Three"
"\n\n"
"This is an example of an email sent using Notify.gov."
"\n\n"
"https://www.notifications.service.gov.uk"
),
}
if not bool(request.args):
resp = make_response(str(HTMLEmailTemplate(template)))
else:
resp = make_response(
str(
HTMLEmailTemplate(
template,
govuk_banner=govuk_banner,
brand_text=brand_text,
brand_colour=brand_colour,
brand_logo=brand_logo,
brand_banner=brand_banner,
brand_name=brand_name,
)
)
)
resp.headers["X-Frame-Options"] = "SAMEORIGIN"
return resp
@main.route("/documentation")
@user_is_logged_in
def documentation():
@@ -88,6 +196,14 @@ def roadmap():
return render_template("views/roadmap.html", navigation_links=features_nav())
@main.route("/features/email")
@user_is_logged_in
def features_email():
return render_template(
"views/features/emails.html", navigation_links=features_nav()
)
@main.route("/features/sms")
@user_is_logged_in
def features_sms():
@@ -138,20 +254,9 @@ def get_started_old():
@main.route("/using-notify/get-started")
@user_is_logged_in
def get_started():
markdown = convert_markdown_template("get-started")
html_style = apply_html_class(
[
["ol", "usa-process-list"],
["li", "usa-process-list__item padding-bottom-4 margin-top-2"],
["h2", "usa-process-list__heading line-height-sans-3"],
],
markdown,
)
return render_template(
"views/get-started.html",
navigation_links=using_notify_nav(),
content=html_style,
)
@@ -183,6 +288,15 @@ def guidance_index():
)
@main.route("/using-notify/guidance/branding-and-customisation")
@user_is_logged_in
def branding_and_customisation():
return render_template(
"views/guidance/branding-and-customisation.html",
navigation_links=using_notify_nav(),
)
@main.route("/using-notify/guidance/create-and-send-messages")
@user_is_logged_in
def create_and_send_messages():

View File

@@ -9,7 +9,6 @@ from app.event_handlers import (
create_invite_user_to_service_event,
create_mobile_number_change_event,
create_remove_user_from_service_event,
create_resend_user_invite_to_service_event,
)
from app.formatters import redact_mobile_number
from app.main import main
@@ -46,6 +45,7 @@ def manage_users(service_id):
@user_has_permissions("manage_service")
def invite_user(service_id, user_id=None):
form_class = InviteUserForm
form = form_class(
inviter_email_address=current_user.email_address,
all_template_folders=current_service.all_template_folders,
@@ -332,22 +332,3 @@ def cancel_invited_user(service_id, invited_user_id):
flash(f"Invitation cancelled for {invited_user.email_address}", "default_with_tick")
return redirect(url_for("main.manage_users", service_id=service_id))
@main.route(
"/services/<uuid:service_id>/resend-invite/<uuid:invited_user_id>",
methods=["GET"],
)
@user_has_permissions("manage_service")
def resend_invite(service_id, invited_user_id):
current_service.resend_invite(invited_user_id)
invited_user = InvitedUser.by_id_and_service_id(service_id, invited_user_id)
create_resend_user_invite_to_service_event(
email_address=invited_user.email_address,
resent_by_id=current_user.id,
service_id=service_id,
)
flash(f"Invitation resent for {invited_user.email_address}", "default_with_tick")
return redirect(url_for("main.manage_users", service_id=service_id))

View File

@@ -3,7 +3,6 @@ from datetime import datetime
from flask import (
Response,
flash,
jsonify,
render_template,
request,
@@ -11,7 +10,12 @@ from flask import (
url_for,
)
from app import current_service, job_api_client, notification_api_client
from app import (
current_service,
format_date_numeric,
job_api_client,
notification_api_client,
)
from app.main import main
from app.notify_client.api_key_api_client import KEY_TYPE_TEST
from app.utils import (
@@ -21,23 +25,21 @@ from app.utils import (
parse_filter_args,
set_status_filters,
)
from app.utils.csv import generate_notifications_csv, get_user_preferred_timezone
from app.utils.csv import generate_notifications_csv
from app.utils.templates import get_template
from app.utils.user import user_has_permissions
@main.route("/services/<uuid:service_id>/notification/<uuid:notification_id>")
@user_has_permissions("view_activity", "send_messages")
def view_notification(service_id, notification_id, error_message=None):
if error_message:
flash(error_message)
def view_notification(service_id, notification_id):
notification = notification_api_client.get_notification(
service_id, str(notification_id)
)
notification["template"].update({"reply_to_text": notification["reply_to_text"]})
personalisation = get_all_personalisation_from_notification(notification)
error_message = None
template = get_template(
notification["template"],
@@ -141,9 +143,6 @@ def download_notifications_csv(service_id):
service_data_retention_days = current_service.get_days_of_retention(
filter_args.get("message_type")[0]
)
file_time = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
file_time = f"{file_time} {get_user_preferred_timezone()}"
return Response(
stream_with_context(
generate_notifications_csv(
@@ -160,7 +159,7 @@ def download_notifications_csv(service_id):
mimetype="text/csv",
headers={
"Content-Disposition": 'inline; filename="{} - {} - {} report.csv"'.format(
file_time,
format_date_numeric(datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")),
filter_args["message_type"][0],
current_service.name,
)

View File

@@ -6,13 +6,21 @@ from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user
from notifications_python_client.errors import HTTPError
from app import current_organization, org_invite_api_client, organizations_client
from app import (
current_organization,
email_branding_client,
org_invite_api_client,
organizations_client,
)
from app.main import main
from app.main.forms import (
AdminBillingDetailsForm,
AdminNewOrganizationForm,
AdminNotesForm,
AdminOrganizationDomainsForm,
AdminOrganizationGoLiveNotesForm,
AdminPreviewBrandingForm,
AdminSetEmailBrandingForm,
InviteOrgUserForm,
OrganizationOrganizationTypeForm,
RenameOrganizationForm,
@@ -23,6 +31,7 @@ from app.main.views.dashboard import (
get_tuples_of_financial_years,
requested_and_current_financial_year,
)
from app.main.views.service_settings import get_branding_as_value_and_label
from app.models.organization import AllOrganizations, Organization
from app.models.user import InvitedOrgUser, User
from app.utils.csv import Spreadsheet
@@ -274,6 +283,58 @@ def edit_organization_type(org_id):
)
@main.route(
"/organizations/<uuid:org_id>/settings/set-email-branding", methods=["GET", "POST"]
)
@user_is_platform_admin
def edit_organization_email_branding(org_id):
email_branding = email_branding_client.get_all_email_branding()
form = AdminSetEmailBrandingForm(
all_branding_options=get_branding_as_value_and_label(email_branding),
current_branding=current_organization.email_branding_id,
)
if form.validate_on_submit():
return redirect(
url_for(
".organization_preview_email_branding",
org_id=org_id,
branding_style=form.branding_style.data,
)
)
return render_template(
"views/organizations/organization/settings/set-email-branding.html",
form=form,
search_form=SearchByNameForm(),
)
@main.route(
"/organizations/<uuid:org_id>/settings/preview-email-branding",
methods=["GET", "POST"],
)
@user_is_platform_admin
def organization_preview_email_branding(org_id):
branding_style = request.args.get("branding_style", None)
form = AdminPreviewBrandingForm(branding_style=branding_style)
if form.validate_on_submit():
current_organization.update(
email_branding_id=form.branding_style.data,
delete_services_cache=True,
)
return redirect(url_for(".organization_settings", org_id=org_id))
return render_template(
"views/organizations/organization/settings/preview-email-branding.html",
form=form,
action=url_for("main.organization_preview_email_branding", org_id=org_id),
)
@main.route(
"/organizations/<uuid:org_id>/settings/edit-organization-domains",
methods=["GET", "POST"],
@@ -312,6 +373,28 @@ def edit_organization_domains(org_id):
)
@main.route(
"/organizations/<uuid:org_id>/settings/edit-go-live-notes", methods=["GET", "POST"]
)
@user_is_platform_admin
def edit_organization_go_live_notes(org_id):
form = AdminOrganizationGoLiveNotesForm()
if form.validate_on_submit():
organizations_client.update_organization(
org_id, request_to_go_live_notes=form.request_to_go_live_notes.data
)
return redirect(url_for(".organization_settings", org_id=org_id))
org = organizations_client.get_organization(org_id)
form.request_to_go_live_notes.data = org["request_to_go_live_notes"]
return render_template(
"views/organizations/organization/settings/edit-go-live-notes.html",
form=form,
)
@main.route("/organizations/<uuid:org_id>/settings/notes", methods=["GET", "POST"])
@user_is_platform_admin
def edit_organization_notes(org_id):

View File

@@ -8,15 +8,13 @@ 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
@main.route("/performance")
def performance():
preferred_tz = pytz.timezone(get_user_preferred_timezone())
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(),
start_date=(datetime.now(pytz.utc) - timedelta(days=7)).date(),
end_date=datetime.now(pytz.utc).date(),
)
stats["organizations_using_notify"] = sorted(
[
@@ -37,5 +35,4 @@ def performance():
stats[
"count_of_live_services_and_organizations"
] = status_api_client.get_count_of_live_services_and_organizations()
return render_template("views/performance.html", **stats)

View File

@@ -596,6 +596,13 @@ def clear_cache():
"service-????????-????-????-????-????????????-template-????????-????-????-????-????????????-versions", # noqa
],
),
(
"email_branding",
[
"email_branding",
"email_branding-????????-????-????-????-????????????",
],
),
(
"organization",
[
@@ -682,6 +689,7 @@ def format_stats_by_service(services):
"name": service["name"],
"stats": service["statistics"],
"restricted": service["restricted"],
"research_mode": service["research_mode"],
"created_at": service["created_at"],
"active": service["active"],
}

View File

@@ -0,0 +1,79 @@
from datetime import datetime
from flask import render_template, url_for
from werkzeug.utils import redirect
from app import provider_client
from app.main import main
from app.main.forms import AdminProviderRatioForm
from app.utils.user import user_is_platform_admin
PROVIDER_PRIORITY_MEANING_SWITCHOVER = datetime(2019, 11, 29, 11, 0).isoformat()
@main.route("/providers")
@user_is_platform_admin
def view_providers():
providers = provider_client.get_all_providers()["provider_details"]
domestic_email_providers, domestic_sms_providers, intl_sms_providers = [], [], []
for provider in providers:
if provider["notification_type"] == "sms":
domestic_sms_providers.append(provider)
if provider.get("supports_international", None):
intl_sms_providers.append(provider)
elif provider["notification_type"] == "email":
domestic_email_providers.append(provider)
add_monthly_traffic(domestic_sms_providers)
return render_template(
"views/providers/providers.html",
email_providers=domestic_email_providers,
domestic_sms_providers=domestic_sms_providers,
intl_sms_providers=intl_sms_providers,
)
def add_monthly_traffic(domestic_sms_providers):
total_sms_sent = sum(
provider["current_month_billable_sms"] for provider in domestic_sms_providers
)
for provider in domestic_sms_providers:
percentage = (
(provider["current_month_billable_sms"] / total_sms_sent * 100)
if total_sms_sent
else 0
)
provider["monthly_traffic"] = round(percentage)
@main.route("/provider/edit-sms-provider-ratio", methods=["GET", "POST"])
@user_is_platform_admin
def edit_sms_provider_ratio():
providers = [
provider
for provider in provider_client.get_all_providers()["provider_details"]
if provider["notification_type"] == "sms" and provider["active"]
]
form = AdminProviderRatioForm(providers)
if form.validate_on_submit():
for provider in providers:
field = getattr(form, provider["identifier"])
provider_client.update_provider(provider["id"], field.data)
return redirect(url_for(".view_providers"))
return render_template(
"views/providers/edit-sms-provider-ratio.html", form=form, providers=providers
)
@main.route("/provider/<uuid:provider_id>")
@user_is_platform_admin
def view_provider(provider_id):
versions = provider_client.get_provider_versions(provider_id)
return render_template(
"views/providers/provider.html", provider_versions=versions["data"]
)

View File

@@ -1,10 +1,17 @@
import itertools
import time
import uuid
from string import ascii_uppercase
from zipfile import BadZipFile
from flask import abort, flash, redirect, render_template, request, session, url_for
from flask import (
abort,
current_app,
flash,
redirect,
render_template,
request,
session,
url_for,
)
from flask_login import current_user
from notifications_python_client.errors import HTTPError
from notifications_utils import SMS_CHAR_COUNT_LIMIT
@@ -203,8 +210,6 @@ def set_sender(service_id, template_id):
sender_details = get_sender_details(service_id, template["template_type"])
sender_details = remove_notify_from_sender_options(sender_details)
if len(sender_details) == 1:
session["sender_id"] = sender_details[0]["id"]
@@ -257,25 +262,6 @@ def set_sender(service_id, template_id):
)
def remove_notify_from_sender_options(sender_details):
# Remove US Notify/Notify.gov from users list of sender
# options during message send flow
sender_details = [
sender for sender in sender_details if verify_sender_options(sender)
]
return sender_details
def verify_sender_options(sender):
if sender.get("sms_sender") in ["Notify.gov", "US Notify"] and sender["is_default"]:
return True
if sender.get("sms_sender") not in ["Notify.gov", "US Notify"]:
return True
return False
def get_sender_context(sender_details, template_type):
context = {
"email": {
@@ -488,6 +474,7 @@ def _check_messages(service_id, template_id, upload_id, preview_row):
remaining_messages = current_service.message_limit - notification_count
contents = s3download(service_id, upload_id)
db_template = current_service.get_template_with_user_permission_or_403(
template_id, current_user
)
@@ -780,8 +767,6 @@ def _check_notification(service_id, template_id, exception=None):
back_link = get_back_link(service_id, template, len(placeholders), placeholders)
choose_time_form = ChooseTimeForm()
if (not session.get("recipient")) or not all_placeholders_in_session(
template.placeholders
):
@@ -793,7 +778,6 @@ def _check_notification(service_id, template_id, exception=None):
return dict(
template=template,
back_link=back_link,
choose_time_form=choose_time_form,
**(get_template_error_dict(exception) if exception else {}),
)
@@ -831,6 +815,7 @@ def get_template_error_dict(exception):
@user_has_permissions("send_messages", restrict_admin_usage=True)
def send_notification(service_id, template_id):
recipient = get_recipient()
if not recipient:
return redirect(
url_for(
@@ -840,69 +825,38 @@ def send_notification(service_id, template_id):
)
)
keys = []
values = []
for k, v in session["placeholders"].items():
keys.append(k)
values.append(v)
data = ",".join(keys)
vals = ",".join(values)
data = f"{data}\r\n{vals}"
filename = f"one-off-{current_user.name}-{uuid.uuid4()}.csv"
my_data = {"filename": filename, "template_id": template_id, "data": data}
upload_id = s3upload(service_id, my_data)
form = CsvUploadForm()
form.file.data = my_data
form.file.name = filename
check_message_output = check_messages(service_id, template_id, upload_id, 2)
if "You cannot send to" in check_message_output:
return check_messages(service_id, template_id, upload_id, 2)
job_api_client.create_job(
upload_id,
service_id,
scheduled_for=request.form.get("scheduled_for", ""),
template_id=template_id,
original_file_name=filename,
notification_count=1,
valid="True",
db_template = current_service.get_template_with_user_permission_or_403(
template_id, current_user
)
session.pop("recipient")
session.pop("placeholders")
# We have to wait for the job to run and create the notification in the database
time.sleep(0.1)
notifications = notification_api_client.get_notifications_for_service(
service_id, job_id=upload_id, include_one_off=True
)
attempts = 0
while notifications["total"] == 0 and attempts < 5:
notifications = notification_api_client.get_notifications_for_service(
service_id, job_id=upload_id, include_one_off=True
try:
noti = notification_api_client.send_notification(
service_id,
template_id=db_template["id"],
recipient=recipient,
personalisation=session["placeholders"],
sender_id=session.get("sender_id", None),
)
time.sleep(0.1)
attempts = attempts + 1
if notifications["total"] == 0 and attempts == 5:
# This shows the job we auto-generated for the user
return redirect(
url_for(
"main.view_job",
service_id=service_id,
job_id=upload_id,
except HTTPError as exception:
current_app.logger.error(
'Service {} could not send notification: "{}"'.format(
current_service.id, exception.message
)
)
return render_template(
"views/notifications/check.html",
**_check_notification(service_id, template_id, exception),
)
session.pop("placeholders")
session.pop("recipient")
session.pop("sender_id", None)
return redirect(
url_for(
".view_notification",
service_id=service_id,
from_job=upload_id,
notification_id=notifications["notifications"][0]["id"],
notification_id=noti["id"],
# used to show the final step of the tour (help=3) or not show
# a back link on a just sent one off notification (help=0)
help=request.args.get("help"),

View File

@@ -13,10 +13,12 @@ from flask import (
)
from flask_login import current_user
from notifications_python_client.errors import HTTPError
from notifications_utils.clients.zendesk.zendesk_client import NotifySupportTicket
from app import (
billing_api_client,
current_service,
email_branding_client,
inbound_number_client,
notification_api_client,
organizations_client,
@@ -27,18 +29,23 @@ from app.event_handlers import (
create_resume_service_event,
create_suspend_service_event,
)
from app.extensions import zendesk_client
from app.formatters import email_safe
from app.main import main
from app.main.forms import (
AdminBillingDetailsForm,
AdminNotesForm,
AdminPreviewBrandingForm,
AdminServiceAddDataRetentionForm,
AdminServiceEditDataRetentionForm,
AdminServiceInboundNumberForm,
AdminServiceMessageLimitForm,
AdminServiceRateLimitForm,
AdminServiceSMSAllowanceForm,
AdminSetEmailBrandingForm,
AdminSetOrganizationForm,
ChooseEmailBrandingForm,
EstimateUsageForm,
RenameServiceForm,
SearchByNameForm,
ServiceContactDetailsForm,
@@ -48,10 +55,16 @@ from app.main.forms import (
ServiceSmsSenderForm,
ServiceSwitchChannelForm,
SMSPrefixForm,
SomethingElseBrandingForm,
)
from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES, SENDING_STATUSES
from app.utils.branding import get_email_choices as get_email_branding_choices
from app.utils.time import parse_naive_dt
from app.utils.user import user_has_permissions, user_is_platform_admin
from app.utils.user import (
user_has_permissions,
user_is_gov_user,
user_is_platform_admin,
)
PLATFORM_ADMIN_SERVICE_PERMISSIONS = OrderedDict(
[
@@ -74,6 +87,7 @@ def service_settings(service_id):
return render_template(
"views/service-settings.html",
service_permissions=PLATFORM_ADMIN_SERVICE_PERMISSIONS,
email_branding_options=ChooseEmailBrandingForm(current_service),
)
@@ -113,6 +127,81 @@ def service_name_change(service_id):
)
@main.route(
"/services/<uuid:service_id>/service-settings/request-to-go-live/estimate-usage",
methods=["GET", "POST"],
)
@user_has_permissions("manage_service")
def estimate_usage(service_id):
form = EstimateUsageForm(
volume_email=current_service.volume_email,
volume_sms=current_service.volume_sms,
consent_to_research={
True: "yes",
False: "no",
}.get(current_service.consent_to_research),
)
if form.validate_on_submit():
current_service.update(
volume_email=form.volume_email.data,
volume_sms=form.volume_sms.data,
consent_to_research=(form.consent_to_research.data == "yes"),
)
return redirect(
url_for(
"main.request_to_go_live",
service_id=service_id,
)
)
return render_template(
"views/service-settings/estimate-usage.html",
form=form,
)
@main.route(
"/services/<uuid:service_id>/service-settings/request-to-go-live", methods=["GET"]
)
@user_has_permissions("manage_service")
def request_to_go_live(service_id):
if current_service.live:
return render_template("views/service-settings/service-already-live.html")
return render_template("views/service-settings/request-to-go-live.html")
@main.route(
"/services/<uuid:service_id>/service-settings/request-to-go-live", methods=["POST"]
)
@user_has_permissions("manage_service")
@user_is_gov_user
def submit_request_to_go_live(service_id):
ticket_message = render_template("support-tickets/go-live-request.txt") + "\n"
ticket = NotifySupportTicket(
subject=f"Request to go live - {current_service.name}",
message=ticket_message,
ticket_type=NotifySupportTicket.TYPE_QUESTION,
user_name=current_user.name,
user_email=current_user.email_address,
requester_sees_message_content=False,
org_id=current_service.organization_id,
org_type=current_service.organization_type,
service_id=current_service.id,
)
zendesk_client.send_ticket_to_zendesk(ticket)
current_service.update(go_live_user=current_user.id)
flash(
"Thanks for your request to go live. Well get back to you within one working day.",
"default",
)
return redirect(url_for(".service_settings", service_id=service_id))
@main.route(
"/services/<uuid:service_id>/service-settings/switch-live", methods=["GET", "POST"]
)
@@ -124,14 +213,6 @@ def service_switch_live(service_id):
if form.validate_on_submit():
current_service.update_status(live=form.enabled.data)
if form.enabled.data is True:
billing_api_client.create_or_update_free_sms_fragment_limit(
service_id, 250000
)
else:
billing_api_client.create_or_update_free_sms_fragment_limit(
service_id, 40000
)
return redirect(url_for(".service_settings", service_id=service_id))
return render_template(
@@ -795,6 +876,57 @@ def set_rate_limit(service_id):
)
@main.route(
"/services/<uuid:service_id>/service-settings/set-email-branding",
methods=["GET", "POST"],
)
@user_is_platform_admin
def service_set_email_branding(service_id):
email_branding = email_branding_client.get_all_email_branding()
form = AdminSetEmailBrandingForm(
all_branding_options=get_branding_as_value_and_label(email_branding),
current_branding=current_service.email_branding_id,
)
if form.validate_on_submit():
return redirect(
url_for(
".service_preview_email_branding",
service_id=service_id,
branding_style=form.branding_style.data,
)
)
return render_template(
"views/service-settings/set-email-branding.html",
form=form,
search_form=SearchByNameForm(),
)
@main.route(
"/services/<uuid:service_id>/service-settings/preview-email-branding",
methods=["GET", "POST"],
)
@user_is_platform_admin
def service_preview_email_branding(service_id):
branding_style = request.args.get("branding_style", None)
form = AdminPreviewBrandingForm(branding_style=branding_style)
if form.validate_on_submit():
current_service.update(email_branding=form.branding_style.data)
return redirect(url_for(".service_settings", service_id=service_id))
return render_template(
"views/service-settings/preview-email-branding.html",
form=form,
service_id=service_id,
action=url_for("main.service_preview_email_branding", service_id=service_id),
)
@main.route(
"/services/<uuid:service_id>/service-settings/link-service-to-organization",
methods=["GET", "POST"],
@@ -825,6 +957,145 @@ def link_service_to_organization(service_id):
)
def create_email_branding_zendesk_ticket(form_option_selected, detail=None):
form = ChooseEmailBrandingForm(current_service)
ticket_message = render_template(
"support-tickets/branding-request.txt",
current_branding=current_service.email_branding_name,
branding_requested=dict(form.options.choices)[form_option_selected],
detail=detail,
)
ticket = NotifySupportTicket(
subject=f"Email branding request - {current_service.name}",
message=ticket_message,
ticket_type=NotifySupportTicket.TYPE_QUESTION,
user_name=current_user.name,
user_email=current_user.email_address,
org_id=current_service.organization_id,
org_type=current_service.organization_type,
service_id=current_service.id,
)
zendesk_client.send_ticket_to_zendesk(ticket)
@main.route(
"/services/<uuid:service_id>/service-settings/email-branding",
methods=["GET", "POST"],
)
@user_has_permissions("manage_service")
def email_branding_request(service_id):
form = ChooseEmailBrandingForm(current_service)
branding_name = current_service.email_branding_name
if form.validate_on_submit():
return redirect(
url_for(
f".email_branding_{form.options.data}",
service_id=current_service.id,
)
)
return render_template(
"views/service-settings/branding/email-branding-options.html",
form=form,
branding_name=branding_name,
)
def check_email_branding_allowed_for_service(branding):
allowed_branding_for_service = dict(get_email_branding_choices(current_service))
if branding not in allowed_branding_for_service:
abort(404)
@main.route(
"/services/<uuid:service_id>/service-settings/email-branding/govuk",
methods=["GET", "POST"],
)
@user_has_permissions("manage_service")
def email_branding_govuk(service_id):
check_email_branding_allowed_for_service("govuk")
if request.method == "POST":
current_service.update(email_branding=None)
flash("Youve updated your email branding", "default")
return redirect(url_for(".service_settings", service_id=current_service.id))
return render_template("views/service-settings/branding/email-branding-govuk.html")
@main.route(
"/services/<uuid:service_id>/service-settings/email-branding/govuk-and-org",
methods=["GET", "POST"],
)
@user_has_permissions("manage_service")
def email_branding_govuk_and_org(service_id):
check_email_branding_allowed_for_service("govuk_and_org")
if request.method == "POST":
create_email_branding_zendesk_ticket("govuk_and_org")
flash(
"Thanks for your branding request. Well get back to you within one working day.",
"default",
)
return redirect(url_for(".service_settings", service_id=current_service.id))
return render_template(
"views/service-settings/branding/email-branding-govuk-org.html"
)
@main.route(
"/services/<uuid:service_id>/service-settings/email-branding/organization",
methods=["GET", "POST"],
)
@user_has_permissions("manage_service")
def email_branding_organization(service_id):
check_email_branding_allowed_for_service("organization")
if request.method == "POST":
create_email_branding_zendesk_ticket("organization")
flash(
"Thanks for your branding request. Well get back to you within one working day.",
"default",
)
return redirect(url_for(".service_settings", service_id=current_service.id))
return render_template(
"views/service-settings/branding/email-branding-organization.html"
)
@main.route(
"/services/<uuid:service_id>/service-settings/email-branding/something-else",
methods=["GET", "POST"],
)
@user_has_permissions("manage_service")
def email_branding_something_else(service_id):
form = SomethingElseBrandingForm()
if form.validate_on_submit():
create_email_branding_zendesk_ticket(
"something_else", detail=form.something_else.data
)
flash(
"Thanks for your branding request. Well get back to you within one working day.",
"default",
)
return redirect(url_for(".service_settings", service_id=current_service.id))
return render_template(
"views/service-settings/branding/email-branding-something-else.html",
form=form,
branding_options=ChooseEmailBrandingForm(current_service),
)
@main.route("/services/<uuid:service_id>/data-retention", methods=["GET"])
@user_is_platform_admin
def data_retention(service_id):
@@ -913,6 +1184,10 @@ def edit_service_billing_details(service_id):
)
def get_branding_as_value_and_label(email_branding):
return [(branding["id"], branding["name"]) for branding in email_branding]
def convert_dictionary_to_wtforms_choices_format(dictionary, value, label):
return [(item[value], item[label]) for item in dictionary]

View File

@@ -1,13 +1,8 @@
import os
import time
import uuid
import jwt
import requests
from flask import (
Markup,
abort,
current_app,
flash,
redirect,
render_template,
@@ -26,100 +21,22 @@ from app.utils import hide_from_search_engines
from app.utils.login import is_safe_redirect_url
def _reformat_keystring(orig):
new_keystring = orig.replace("-----BEGIN PRIVATE KEY-----", "")
new_keystring = new_keystring.replace("-----END PRIVATE KEY-----", "")
new_keystring = new_keystring.strip()
new_keystring = new_keystring.replace(" ", "\n")
new_keystring = "\n".join(
["-----BEGIN PRIVATE KEY-----", new_keystring, "-----END PRIVATE KEY-----"]
)
new_keystring = f"{new_keystring}\n"
return new_keystring
def _get_access_token(code, state):
client_id = os.getenv("LOGIN_DOT_GOV_CLIENT_ID")
access_token_url = os.getenv("LOGIN_DOT_GOV_ACCESS_TOKEN_URL")
keystring = os.getenv("LOGIN_PEM")
if " " in keystring:
keystring = _reformat_keystring(keystring)
payload = {
"iss": client_id,
"sub": client_id,
"aud": access_token_url,
"jti": str(uuid.uuid4()),
# JWT expiration time (10 minute maximum)
"exp": int(time.time()) + (10 * 60),
}
current_app.logger.warning(f"Here is the raw payload {payload}")
token = jwt.encode(payload, keystring, algorithm="RS256")
base_url = f"{access_token_url}?"
cli_assert = f"client_assertion={token}"
cli_assert_type = "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
code_param = f"code={code}"
url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code"
current_app.logger.info(f"This is the url we use to get the access token: {url}")
headers = {"Authorization": "Bearer %s" % token}
response = requests.post(url, headers=headers)
current_app.logger.info(f"GOT A RESPONSE {response.json()}")
access_token = response.json()["access_token"]
return access_token
def _get_user_email(access_token):
headers = {"Authorization": "Bearer %s" % access_token}
user_info_url = os.getenv("LOGIN_DOT_GOV_USER_INFO_URL")
user_attributes = requests.get(
user_info_url,
headers=headers,
)
user_email = user_attributes.json()["email"]
return user_email
@main.route("/sign-in", methods=(["GET", "POST"]))
@hide_from_search_engines
def sign_in():
# start login.gov
code = request.args.get("code")
state = request.args.get("state")
login_gov_error = request.args.get("error")
if code and state:
access_token = _get_access_token(code, state)
user_email = _get_user_email(access_token)
redirect_url = request.args.get("next")
# activate the user
user = user_api_client.get_user_by_email(user_email)
activate_user(user["id"])
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
elif login_gov_error:
current_app.logger.error(f"login.gov error: {login_gov_error}")
raise Exception(f"Could not login with login.gov {login_gov_error}")
# end login.gov
redirect_url = request.args.get("next")
if os.getenv("NOTIFY_E2E_TEST_EMAIL"):
current_app.logger.warning("E2E TESTS ARE ENABLED.")
current_app.logger.warning(
"If you are getting a 404 on signin, comment out E2E vars in .env file!"
)
user = user_api_client.get_user_by_email(os.getenv("NOTIFY_E2E_TEST_EMAIL"))
activate_user(user["id"])
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
current_app.logger.info(f"current user is {current_user}")
if current_user and current_user.is_authenticated:
if redirect_url and is_safe_redirect_url(redirect_url):
return redirect(redirect_url)
return redirect(url_for("main.show_accounts_or_dashboard"))
form = LoginForm()
current_app.logger.info("Got the login form")
password_reset_url = url_for(".forgot_password", next=request.args.get("next"))
if form.validate_on_submit():
@@ -167,40 +84,12 @@ def sign_in():
)
other_device = current_user.logged_in_elsewhere()
notify_env = os.getenv("NOTIFY_ENVIRONMENT")
current_app.logger.info("should render the sign in template")
# TODO REMOVE THIS INFO ONCE STAGING WORKS WITH LOGIN DOT GOV
current_app.logger.info(f"NOTIFY ENV = {notify_env}")
current_app.logger.info(
f"LOGIN_DOT_GOV_CLIENT_ID={os.getenv('LOGIN_DOT_GOV_CLIENT_ID')}"
)
current_app.logger.info(
f"LOGIN_DOT_GOV_USER_INFO_URL={os.getenv('LOGIN_DOT_GOV_USER_INFO_URL')}"
)
current_app.logger.info(
f"LOGIN_DOT_GOV_ACCESS_TOKEN_URL={os.getenv('LOGIN_DOT_GOV_ACCESS_TOKEN_URL')}"
)
current_app.logger.info(
f"LOGIN_DOT_GOV_LOGOUT_URL={os.getenv('LOGIN_DOT_GOV_LOGOUT_URL')}"
)
current_app.logger.info(
f"LOGIN_DOT_GOV_BASE_LOGOUT_URL={os.getenv('LOGIN_DOT_GOV_BASE_LOGOUT_URL')}"
)
current_app.logger.info(
f"LOGIN_DOT_GOV_SIGNOUT_REDIRECT={os.getenv('LOGIN_DOT_GOV_SIGNOUT_REDIRECT')}"
)
initial_signin_url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL")
current_app.logger.info(f"LOGIN_DOT_GOV_INITIAL_SIGNIN_URL={initial_signin_url}")
return render_template(
"views/signin.html",
form=form,
again=bool(redirect_url),
other_device=other_device,
login_gov_enabled=bool(notify_env in ["development", "staging"]),
password_reset_url=password_reset_url,
initial_signin_url=initial_signin_url,
)

View File

@@ -1,40 +1,12 @@
import os
import requests
from flask import current_app, redirect, url_for
from flask import redirect, url_for
from flask_login import current_user
from app.main import main
# ask login.gov if we really need manual logout and what's up with one hour sessions
# ask login.gov how they recommend approaching dev environment
# ask Tim Donaworth the same for #2
def _sign_out_at_login_dot_gov():
base_url = os.getenv("LOGIN_DOT_GOV_BASE_LOGOUT_URL")
client_id = f"client_id={os.getenv('LOGIN_DOT_GOV_CLIENT_ID')}"
post_logout_redirect_uri = (
f"post_logout_redirect_uri={os.getenv('LOGIN_DOT_GOV_SIGNOUT_REDIRECT')}"
)
url = f"{base_url}{client_id}&{post_logout_redirect_uri}"
current_app.logger.info(f"url={url}")
response = requests.post(url)
# response = requests.post(url)
current_app.logger.info(f"login.gov response: {response.text}")
@main.route("/sign-out", methods=(["GET", "POST"]))
@main.route("/sign-out", methods=(["GET"]))
def sign_out():
# An AnonymousUser does not have an id
current_app.logger.info("HIT THE REGULAR SIGN OUT")
if current_user.is_authenticated:
# TODO This doesn't work yet, due to problems above.
current_user.sign_out()
login_dot_gov_logout_url = os.getenv("LOGIN_DOT_GOV_LOGOUT_URL")
if login_dot_gov_logout_url:
return redirect(login_dot_gov_logout_url)
return redirect(url_for("main.index"))

View File

@@ -4,6 +4,10 @@ def features_nav():
"name": "Features",
"link": "main.features",
"sub_navigation_items": [
# {
# "name": "Emails",
# "link": "main.features_email",
# },
# {
# "name": "Text messages",
# "link": "main.features_sms",
@@ -52,6 +56,10 @@ def using_notify_nav():
# "link": "main.edit_and_format_messages",
# },
# {
# "name": "Branding",
# "link": "main.branding_and_customisation",
# },
# {
# "name": "Send files by email",
# "link": "main.send_files_by_email",
# },

View File

@@ -2,7 +2,6 @@ from functools import partial
from flask import abort, flash, jsonify, redirect, render_template, request, url_for
from flask_login import current_user
from markupsafe import Markup
from notifications_python_client.errors import HTTPError
from notifications_utils import SMS_CHAR_COUNT_LIMIT
@@ -680,23 +679,8 @@ def count_content_length(service_id, template_type):
)
def _is_latin1(s):
return bool(s.encode(encoding="latin-1", errors="strict"))
def _get_content_count_error_and_message_for_template(template):
url = "https://en.wikipedia.org/wiki/ISO/IEC_8859-1"
if template.template_type == "sms":
s1 = f"<html><body>Use of characters outside the <a href='{url}'>IEC_8859-1</a> character set may increase "
s2 = "the message fragment count, resulting in additional charges, and these IEC_8859-1 "
s3 = "characters may not display properly on some older mobile devices.</body></html>"
warning = ""
try:
_is_latin1(template.content)
except UnicodeEncodeError:
warning = f"{s1}{s2}{s3}"
if template.is_message_too_long():
return True, (
f"You have "
@@ -705,16 +689,11 @@ def _get_content_count_error_and_message_for_template(template):
)
if template.placeholders:
return False, (
Markup(
f"Will be charged as {message_count(template.fragment_count, template.template_type)} "
f"(not including personalization). {warning}"
)
f"Will be charged as {message_count(template.fragment_count, template.template_type)} "
f"(not including personalization)"
)
return False, (
# Markup marks html contents safe so that they render properly. Don't use it if there is user input.
Markup(
f"Will be charged as {message_count(template.fragment_count, template.template_type)}. {warning} "
)
f"Will be charged as {message_count(template.fragment_count, template.template_type)} "
)

View File

@@ -24,7 +24,6 @@ from app.main.forms import (
ChangeMobileNumberForm,
ChangeNameForm,
ChangePasswordForm,
ChangePreferredTimezoneForm,
ConfirmPasswordForm,
ServiceOnOffSettingForm,
TwoFactorForm,
@@ -62,22 +61,6 @@ def user_profile_name():
)
@main.route("/user-profile/preferred_timezone", methods=["GET", "POST"])
@user_is_logged_in
def user_profile_preferred_timezone():
form = ChangePreferredTimezoneForm(new_name=current_user.preferred_timezone)
if form.validate_on_submit():
current_user.update(preferred_timezone=form.new_preferred_timezone.data)
current_user.preferred_timezone = form.new_preferred_timezone.data
return redirect(url_for(".user_profile"))
return render_template(
"views/user-profile/change.html",
thing="preferred timezone",
form_field=form.new_preferred_timezone,
)
@main.route("/user-profile/email", methods=["GET", "POST"])
@user_is_logged_in
@user_is_gov_user

View File

@@ -66,6 +66,9 @@ class ServiceEvent(Event):
def format_contact_link(self):
return "Set the contact details for this service to {}".format(self.value_to)
def format_email_branding(self):
return "Updated this services email branding"
def format_inbound_api(self):
return "Updated the callback for received text messages"
@@ -113,6 +116,9 @@ class ServiceEvent(Event):
def format_service_callback_api(self):
return "Updated the callback for delivery receipts"
def format_go_live_user(self):
return "Requested for this service to go live"
class APIKeyEvent(Event):
relevant = True

3
app/models/feedback.py Normal file
View File

@@ -0,0 +1,3 @@
QUESTION_TICKET_TYPE = "ask-question-give-feedback"
PROBLEM_TICKET_TYPE = "report-problem"
GENERAL_TICKET_TYPE = "general"

View File

@@ -3,6 +3,7 @@ from collections import OrderedDict
from werkzeug.utils import cached_property
from app.models import JSONModel, ModelList, SerialisedModelCollection, SortByNameMixin
from app.notify_client.email_branding_client import email_branding_client
from app.notify_client.organizations_api_client import organizations_client
@@ -24,7 +25,9 @@ class Organization(JSONModel, SortByNameMixin):
"name",
"active",
"organization_type",
"email_branding_id",
"domains",
"request_to_go_live_notes",
"count_of_live_services",
"billing_contact_email_addresses",
"billing_contact_names",
@@ -70,6 +73,8 @@ class Organization(JSONModel, SortByNameMixin):
self.name = None
self.domains = []
self.organization_type = None
self.request_to_go_live_notes = None
self.email_branding_id = None
@property
def organization_type_label(self):
@@ -123,6 +128,19 @@ class Organization(JSONModel, SortByNameMixin):
key=lambda user: user.email_address.lower(),
)
@cached_property
def email_branding(self):
if self.email_branding_id:
return email_branding_client.get_email_branding(self.email_branding_id)[
"email_branding"
]
@property
def email_branding_name(self):
if self.email_branding_id:
return self.email_branding["name"]
return "GOV.UK"
def update(self, delete_services_cache=False, **kwargs):
response = organizations_client.update_organization(
self.id,

View File

@@ -8,6 +8,7 @@ from app.models.organization import Organization
from app.models.user import InvitedUsers, User, Users
from app.notify_client.api_key_api_client import api_key_api_client
from app.notify_client.billing_api_client import billing_api_client
from app.notify_client.email_branding_client import email_branding_client
from app.notify_client.inbound_number_client import inbound_number_client
from app.notify_client.invite_api_client import invite_api_client
from app.notify_client.job_api_client import job_api_client
@@ -190,15 +191,6 @@ class Service(JSONModel, SortByNameMixin):
invited_user_id=str(invited_user_id),
)
def resend_invite(self, invited_user_id):
if str(invited_user_id) not in {user.id for user in self.invited_users}:
abort(404)
return invite_api_client.resend_invite(
service_id=self.id,
invited_user_id=str(invited_user_id),
)
def get_team_member(self, user_id):
if str(user_id) not in {user.id for user in self.active_users}:
abort(404)
@@ -379,6 +371,22 @@ class Service(JSONModel, SortByNameMixin):
)
)
@property
def go_live_checklist_completed(self):
return all(
(
bool(self.volumes),
self.has_team_members,
self.has_templates,
not self.needs_to_add_email_reply_to_address,
not self.needs_to_change_sms_sender,
)
)
@property
def go_live_checklist_completed_as_yes_no(self):
return "Yes" if self.go_live_checklist_completed else "No"
@cached_property
def free_sms_fragment_limit(self):
return billing_api_client.get_free_sms_fragment_limit_for_year(self.id) or 0
@@ -400,6 +408,31 @@ class Service(JSONModel, SortByNameMixin):
{},
).get("days_of_retention", current_app.config["ACTIVITY_STATS_LIMIT_DAYS"])
@property
def email_branding_id(self):
return self._dict["email_branding"]
@cached_property
def email_branding(self):
if self.email_branding_id:
return email_branding_client.get_email_branding(self.email_branding_id)[
"email_branding"
]
return None
@cached_property
def email_branding_name(self):
if self.email_branding is None:
return "GOV.UK"
return self.email_branding["name"]
@property
def needs_to_change_email_branding(self):
return (
self.email_branding_id is None
and self.organization_type != Organization.TYPE_CENTRAL
)
@cached_property
def organization(self):
return Organization.from_id(self.organization_id)

View File

@@ -47,7 +47,6 @@ class User(JSONModel, UserMixin):
"password_changed_at",
"permissions",
"state",
"preferred_timezone",
}
def __init__(self, _dict):
@@ -365,7 +364,6 @@ class User(JSONModel, UserMixin):
"permissions": [x for x in self._permissions],
"organizations": self.organization_ids,
"current_session_id": self.current_session_id,
"preferred_timezone": self.preferred_timezone,
}
if hasattr(self, "_password"):
dct["password"] = self._password

View File

@@ -38,10 +38,16 @@ class Navigation:
class HeaderNavigation(Navigation):
mapping = {
"support": {
"bat_phone",
"feedback",
"support",
"support_public",
"thanks",
"triage",
},
"features": {
"features",
"features_email",
"features_sms",
"roadmap",
"security",
@@ -87,7 +93,49 @@ class HeaderNavigation(Navigation):
"uploads",
"view_job",
"view_jobs",
"confirm_edit_user_email",
"confirm_edit_user_mobile_number",
"edit_user_email",
"edit_user_mobile_number",
"edit_user_permissions",
"invite_user",
"manage_users",
"remove_user_from_service",
"usage",
"email_branding_govuk",
"email_branding_govuk_and_org",
"email_branding_organization",
"email_branding_request",
"email_branding_something_else",
"estimate_usage",
"link_service_to_organization",
"request_to_go_live",
"service_add_email_reply_to",
"service_add_sms_sender",
"service_confirm_delete_email_reply_to",
"service_confirm_delete_sms_sender",
"service_edit_email_reply_to",
"service_edit_sms_sender",
"service_email_reply_to",
"service_name_change",
"service_preview_email_branding",
"service_set_auth_type",
"service_set_channel",
"send_files_by_email_contact_details",
"service_set_email_branding",
"service_set_inbound_number",
"service_set_inbound_sms",
"service_set_international_sms",
"service_set_reply_to_email",
"service_set_sms_prefix",
"service_verify_reply_to_address",
"service_verify_reply_to_address_updates",
"service_settings",
"service_sms_senders",
"set_free_sms_allowance",
"set_message_limit",
"set_rate_limit",
"submit_request_to_go_live",
},
"pricing": {
"how_to_pay",
@@ -109,13 +157,15 @@ class HeaderNavigation(Navigation):
"user_profile_mobile_number_delete",
"user_profile_name",
"user_profile_password",
"user_profile_preferred_timezone",
"user_profile_disable_platform_admin_view",
},
"platform-admin": {
"archive_user",
"change_user_auth",
"clear_cache",
"create_email_branding",
"edit_sms_provider_ratio",
"email_branding",
"find_services_by_name",
"find_users_by_email",
"live_services",
@@ -133,7 +183,10 @@ class HeaderNavigation(Navigation):
"platform_admin_splash_page",
"suspend_service",
"trial_services",
"update_email_branding",
"user_information",
"view_provider",
"view_providers",
},
"sign-in": {
"revalidate_email_sent",
@@ -204,23 +257,16 @@ class MainNavigation(Navigation):
"usage": {
"usage",
},
"user-profile": {
"user_profile",
"user_profile_confirm_delete_mobile_number",
"user_profile_email",
"user_profile_email_authenticate",
"user_profile_email_confirm",
"user_profile_mobile_number",
"user_profile_mobile_number_authenticate",
"user_profile_mobile_number_confirm",
"user_profile_mobile_number_delete",
"user_profile_name",
"user_profile_password",
"user_profile_preferred_timezone",
"user_profile_disable_platform_admin_view",
},
"settings": {
# 'add_organization_from_gp_service',
"email_branding_govuk",
"email_branding_govuk_and_org",
"email_branding_organization",
"email_branding_request",
"email_branding_something_else",
"estimate_usage",
"link_service_to_organization",
"request_to_go_live",
"service_add_email_reply_to",
"service_add_sms_sender",
"service_confirm_delete_email_reply_to",
@@ -229,9 +275,11 @@ class MainNavigation(Navigation):
"service_edit_sms_sender",
"service_email_reply_to",
"service_name_change",
"service_preview_email_branding",
"service_set_auth_type",
"service_set_channel",
"send_files_by_email_contact_details",
"service_set_email_branding",
"service_set_inbound_number",
"service_set_inbound_sms",
"service_set_international_sms",
@@ -244,6 +292,7 @@ class MainNavigation(Navigation):
"set_free_sms_allowance",
"set_message_limit",
"set_rate_limit",
"submit_request_to_go_live",
},
"api-integration": {
"api_callbacks",
@@ -280,58 +329,6 @@ class CaseworkNavigation(Navigation):
}
class SecondaryNavigation(Navigation):
mapping = {
"settings": {
"link_service_to_organization",
"service_add_email_reply_to",
"service_add_sms_sender",
"service_confirm_delete_email_reply_to",
"service_confirm_delete_sms_sender",
"service_edit_email_reply_to",
"service_edit_sms_sender",
"service_email_reply_to",
"service_name_change",
"service_set_auth_type",
"service_set_channel",
"send_files_by_email_contact_details",
"service_set_inbound_number",
"service_set_inbound_sms",
"service_set_international_sms",
"service_set_reply_to_email",
"service_set_sms_prefix",
"service_verify_reply_to_address",
"service_verify_reply_to_address_updates",
"service_settings",
"service_sms_senders",
"set_free_sms_allowance",
"set_message_limit",
"set_rate_limit",
"confirm_edit_user_email",
"confirm_edit_user_mobile_number",
"edit_user_email",
"edit_user_mobile_number",
"edit_user_permissions",
"invite_user",
"manage_users",
"remove_user_from_service",
"user_profile",
"user_profile_confirm_delete_mobile_number",
"user_profile_email",
"user_profile_email_authenticate",
"user_profile_email_confirm",
"user_profile_mobile_number",
"user_profile_mobile_number_authenticate",
"user_profile_mobile_number_confirm",
"user_profile_mobile_number_delete",
"user_profile_name",
"user_profile_password",
"user_profile_preferred_timezone",
"user_profile_disable_platform_admin_view",
},
}
class OrgNavigation(Navigation):
mapping = {
"dashboard": {
@@ -340,9 +337,12 @@ class OrgNavigation(Navigation):
"settings": {
"edit_organization_billing_details",
"edit_organization_domains",
"edit_organization_email_branding",
"edit_organization_go_live_notes",
"edit_organization_name",
"edit_organization_notes",
"edit_organization_type",
"organization_preview_email_branding",
"organization_settings",
},
"team-members": {

View File

@@ -0,0 +1,40 @@
from app.notify_client import NotifyAdminAPIClient, cache
class EmailBrandingClient(NotifyAdminAPIClient):
@cache.set("email_branding-{branding_id}")
def get_email_branding(self, branding_id):
return self.get(url="/email-branding/{}".format(branding_id))
@cache.set("email_branding")
def get_all_email_branding(self, sort_key=None):
brandings = self.get(url="/email-branding")["email_branding"]
if sort_key and sort_key in brandings[0]:
brandings.sort(key=lambda branding: branding[sort_key].lower())
return brandings
@cache.delete("email_branding")
def create_email_branding(self, logo, name, text, colour, brand_type):
data = {
"logo": logo,
"name": name,
"text": text,
"colour": colour,
"brand_type": brand_type,
}
return self.post(url="/email-branding", data=data)
@cache.delete("email_branding")
@cache.delete("email_branding-{branding_id}")
def update_email_branding(self, branding_id, logo, name, text, colour, brand_type):
data = {
"logo": logo,
"name": name,
"text": text,
"colour": colour,
"brand_type": brand_type,
}
return self.post(url="/email-branding/{}".format(branding_id), data=data)
email_branding_client = EmailBrandingClient()

View File

@@ -32,11 +32,11 @@ class InviteApiClient(NotifyAdminAPIClient):
"folder_permissions": folder_permissions,
}
data = _attach_current_user(data)
resp = self.post(url=f"/service/{service_id}/invite", data=data)
resp = self.post(url="/service/{}/invite".format(service_id), data=data)
return resp["data"]
def get_invites_for_service(self, service_id):
return self.get(f"/service/{service_id}/invite")["data"]
return self.get("/service/{}/invite".format(service_id))["data"]
def get_invited_user(self, invited_user_id):
return self.get(f"/invite/service/{invited_user_id}")["data"]
@@ -46,7 +46,7 @@ class InviteApiClient(NotifyAdminAPIClient):
def get_count_of_invites_with_permission(self, service_id, permission):
if permission not in all_ui_permissions:
raise TypeError(f"{permission} is not a valid permission")
raise TypeError("{} is not a valid permission".format(permission))
return len(
[
invited_user
@@ -56,21 +56,22 @@ class InviteApiClient(NotifyAdminAPIClient):
)
def check_token(self, token):
return self.get(url=f"/invite/service/check/{token}")["data"]
return self.get(url="/invite/service/check/{}".format(token))["data"]
def cancel_invited_user(self, service_id, invited_user_id):
data = {"status": "cancelled"}
data = _attach_current_user(data)
self.post(url=f"/service/{service_id}/invite/{invited_user_id}", data=data)
def resend_invite(self, service_id, invited_user_id):
self.post(url=f"/service/{service_id}/invite/{invited_user_id}/resend", data={})
self.post(
url="/service/{0}/invite/{1}".format(service_id, invited_user_id), data=data
)
@cache.delete("service-{service_id}")
@cache.delete("user-{invited_user_id}")
def accept_invite(self, service_id, invited_user_id):
data = {"status": "accepted"}
self.post(url=f"/service/{service_id}/invite/{invited_user_id}", data=data)
self.post(
url="/service/{0}/invite/{1}".format(service_id, invited_user_id), data=data
)
invite_api_client = InviteApiClient()

View File

@@ -1,9 +1,5 @@
import datetime
from zoneinfo import ZoneInfo
from app.extensions import redis_client
from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache
from app.utils.csv import get_user_preferred_timezone
class JobApiClient(NotifyAdminAPIClient):
@@ -90,45 +86,11 @@ class JobApiClient(NotifyAdminAPIClient):
def has_jobs(self, service_id):
return bool(self.get_jobs(service_id)["data"])
@classmethod
def convert_user_time_to_utc(cls, scheduled_for):
user_preferred_tz = get_user_preferred_timezone()
user_date = datetime.datetime.fromisoformat(scheduled_for)
scheduled_for = (
user_date.replace(tzinfo=ZoneInfo(user_preferred_tz))
.astimezone(ZoneInfo("UTC"))
.strftime("%Y-%m-%dT%H:%M:%S")
)
return scheduled_for
def create_job(
self,
job_id,
service_id,
scheduled_for=None,
template_id=None,
original_file_name=None,
notification_count=None,
valid=None,
):
def create_job(self, job_id, service_id, scheduled_for=None):
data = {"id": job_id}
# make a datetime object in the user's preferred timezone
if scheduled_for:
scheduled_for = JobApiClient.convert_user_time_to_utc(scheduled_for)
data["scheduled_for"] = scheduled_for
if template_id:
data["template_id"] = template_id
if original_file_name:
data["original_file_name"] = original_file_name
if notification_count:
data["notification_count"] = notification_count
if valid:
data["valid"] = valid
data.update({"scheduled_for": scheduled_for})
data = _attach_current_user(data)
job = self.post(url="/service/{}/job".format(service_id), data=data)

View File

@@ -0,0 +1,20 @@
from app.notify_client import NotifyAdminAPIClient, _attach_current_user
class ProviderClient(NotifyAdminAPIClient):
def get_all_providers(self):
return self.get(url="/provider-details")
def get_provider_by_id(self, provider_id):
return self.get(url="/provider-details/{}".format(provider_id))
def get_provider_versions(self, provider_id):
return self.get(url="/provider-details/{}/versions".format(provider_id))
def update_provider(self, provider_id, priority):
data = {"priority": priority}
data = _attach_current_user(data)
return self.post(url="/provider-details/{}".format(provider_id), data=data)
provider_client = ProviderClient()

View File

@@ -82,6 +82,7 @@ class ServiceAPIClient(NotifyAdminAPIClient):
"contact_link",
"created_by",
"count_as_live",
"email_branding",
"email_from",
"free_sms_fragment_limit",
"go_live_at",

View File

@@ -3,7 +3,7 @@ from app.notify_client import NotifyAdminAPIClient, cache
class StatusApiClient(NotifyAdminAPIClient):
def get_status(self, *params):
return self.get(*params, url="/_status")
return self.get(url="/_status", *params)
@cache.set("live-service-and-organization-counts", ttl_in_seconds=3600)
def get_count_of_live_services_and_organizations(self):

View File

@@ -12,7 +12,6 @@ ALLOWED_ATTRIBUTES = {
"updated_by",
"current_session_id",
"email_access_validated_at",
"preferred_timezone",
}
@@ -40,7 +39,6 @@ class UserApiClient(NotifyAdminAPIClient):
return self.get("/user/{}".format(user_id))
def get_user_by_email(self, email_address):
current_app.logger.info(f"Going to get user by email {email_address}")
user_data = self.post("/user/email", data={"email": email_address})
return user_data["data"]

View File

@@ -1,11 +1,8 @@
from datetime import datetime
from functools import reduce
import pytz
from dateutil import parser
from app.utils.csv import get_user_preferred_timezone
def sum_of_statistics(delivery_statistics):
statistics_keys = (
@@ -27,7 +24,6 @@ def sum_of_statistics(delivery_statistics):
def add_rates_to(delivery_statistics):
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return dict(
emails_failure_rate=get_formatted_percentage(
delivery_statistics["emails_failed"],
@@ -37,7 +33,7 @@ def add_rates_to(delivery_statistics):
delivery_statistics["sms_failed"], delivery_statistics["sms_requested"]
),
week_end_datetime=parser.parse(
delivery_statistics.get("week_end", str(datetime.now(preferred_tz)))
delivery_statistics.get("week_end", str(datetime.utcnow()))
),
**delivery_statistics
)

View File

@@ -58,37 +58,26 @@
"text": "Features",
"active": header_navigation.is_selected('features')
},
{
"href": url_for('main.user_profile'),
"text": current_user.name,
"active": header_navigation.is_selected('user-profile')
},
{
"href": url_for('main.platform_admin_splash_page'),
"text": "Platform admin",
"active": header_navigation.is_selected('platform-admin')
},
{
"href": url_for('main.sign_out'),
"text": "Sign out"
},
{
"href": url_for('main.support'),
"text": "Contact us",
"active": header_navigation.is_selected('support')
}
] %}
{% if current_service %}
{% set secondaryNavigation = [
{
"href": url_for('main.service_settings', service_id=current_service.id),
"text": "Settings",
"active": secondary_navigation.is_selected('settings')
},
{
"href": url_for('main.sign_out'),
"text": "Sign out"
}
] %}
{% else %}
{% set secondaryNavigation = [
{
"href": url_for('main.sign_out'),
"text": "Sign out"
}
] %}
{% endif %}
{% else %}
{% set navigation = [
{
@@ -115,29 +104,13 @@
"href": url_for('main.user_profile'),
"text": "User profile",
"active": header_navigation.is_selected('user-profile')
},
{
"href": url_for('main.sign_out'),
"text": "Sign out"
}
] %}
{% if current_service %}
{% set secondaryNavigation = [
{
"href": url_for('main.service_settings', service_id=current_service.id),
"text": "Settings",
"active": secondary_navigation.is_selected('settings')
},
{
"href": url_for('main.sign_out'),
"text": "Sign out"
}
] %}
{% else %}
{% set secondaryNavigation = [
{
"href": url_for('main.sign_out'),
"text": "Sign out"
}
] %}
{% endif %}
{% endif %}
{% endif %}
{% else %}
<!-- Add navigation back after pilot -->
{# {% set navigation = [
@@ -169,7 +142,6 @@
"productName": "Notify",
"navigation": navigation,
"navigationClasses": "govuk-header__navigation--end",
"secondaryNavigation": secondaryNavigation,
"assetsPath": asset_path + "images"
}) }}
{% endblock %}
@@ -264,7 +236,7 @@
<span class="usa-sr-only">Please choose to extend your session or sign out. Your session will expire in 5 minutes or less.</span>
</h2>
<div class="usa-prose">
<p>You have been inactive for too long.
<p>You have been inactive for too long.
Your session will expire in <span id="timeLeft" role="timer"></span>.
</p>
</div>
@@ -301,3 +273,5 @@
<!--<![endif]-->
{% endblock %}

View File

@@ -22,3 +22,54 @@
</a>
{% endif %}
{% endmacro %}
{% macro big_number_with_status(
number,
label,
failures,
failure_percentage,
danger_zone=False,
failure_link=None,
link=None,
show_failures=True,
smaller=False,
smallest=False
) %}
<span class="big-number-with-status">
{{ big_number(number, label, link=link, smaller=smaller, smallest=smallest) }}
{% if show_failures %}
<span class="big-number-status{% if danger_zone %} big-number-status--failing{% endif %}">
{% if failures %}
{% if failure_link %}
<a class="usa-link" href="{{ failure_link }}">
{{ "{:,}".format(failures) }}
failed {{ failure_percentage }}%
</a>
{% else %}
{{ "{:,}".format(failures) }}
failed {{ failure_percentage }}%
{% endif %}
{% else %}
No failures
{% endif %}
</span>
{% endif %}
</span>
{% endmacro %}
{% macro big_number_simple(number, label) %}
<span class="big-number-dark bottom-gutter-2-3">
<span class="big-number-number">
{% if number is number %}
{{ "{:,}".format(number) }}
{% else %}
{{ number }}
{% endif %}
</span>
{% if label %}
<span class="big-number-label">{{ label }}</span>
{% endif %}
</span>
{% endmacro %}

View File

@@ -0,0 +1,7 @@
{% macro branding_preview(branding_style, endpoint) %}
<iframe src="{{ url_for(endpoint, branding_style=branding_style) }}" class="branding-preview" scrolling="no"></iframe>
{% endmacro %}
{% macro email_branding_preview(branding_style) %}
{{ branding_preview(branding_style, 'main.email_template') }}
{% endmacro %}

View File

@@ -0,0 +1,15 @@
# Error message
## Installation
See the [main README quick start guide](https://github.com/alphagov/govuk-frontend#quick-start) for how to install this component.
## Guidance and Examples
Find out when to use the error message component in your service in the [GOV.UK Design System](https://design-system.service.gov.uk/components/error-message).
## Component options
Use options to customize the appearance, content and behavior of a component when using a macro, for example, changing the text.
See [options table](https://design-system.service.gov.uk/components/error-message/#options-example-default) for details.

View File

@@ -0,0 +1,37 @@
[
{
"name": "text",
"type": "string",
"required": true,
"description": "If `html` is set, this is not required. Text to use within the error message. If `html` is provided, the `text` argument will be ignored."
},
{
"name": "html",
"type": "string",
"required": true,
"description": "If `text` is set, this is not required. HTML to use within the error message. If `html` is provided, the `text` argument will be ignored."
},
{
"name": "id",
"type": "string",
"required": false,
"description": "Id attribute to add to the error message span tag."
},
{
"name": "classes",
"type": "string",
"required": false,
"description": "Classes to add to the error message span tag."
},
{
"name": "attributes",
"type": "object",
"required": false,
"description": "HTML attributes (for example data attributes) to add to the error message span tag"
},
{
"name": "visuallyHiddenText",
"type": "string",
"description": "A visually hidden prefix used before the error message. Defaults to \"Error\"."
}
]

View File

@@ -0,0 +1,3 @@
{% macro usaAlert(params) %}
{%- include "./template.njk" -%}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{%- if params.slim %}
<div class="usa-alert usa-alert--slim usa-alert--{{params.type}} {%- if params.classes %} {{ params.classes }}{% endif %}" {%- if params.id %} id="{{ params.id }}"{% endif %}>
<div class="usa-alert__body">
<p class="usa-alert__text">
{{params.text}}
</p>
</div>
</div>
{% else %}
<div class="usa-alert usa-alert--{{params.type}} {%- if params.classes %} {{ params.classes }}{% endif %}" {%- if params.id %} id="{{ params.id }}"{% endif %}>
<div class="usa-alert__body">
<h{{params.header_level}} class="usa-alert__heading">{{params.heading}}</h{{params.header_level}}>
<p class="usa-alert__text">
{{params.text | safe }}
</p>
</div>
</div>
{% endif %}

View File

@@ -76,18 +76,23 @@
{% endif %}
{% endfor %}
</ul>
<div class="usa-nav__secondary margin-bottom-2">
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
{% for item in params.secondaryNavigation %}
{% if item.href and item.text %}
<li class="usa-nav__secondary-item{{ ' is-current' if item.active }}">
<a class="usa-nav__link {{ ' usa-current' if item.active }}" href="{{ item.href }}" {% for attribute, value in
item.attributes %} {{attribute}}="{{value}}" {% endfor %}>
<span>{{ item.text }}</span>
</a>
</li>
{% endif %}
{% endfor %}
<li class="usa-nav__secondary-item{{ ' is-current' if item.active }}">
<a class="usa-nav__link {{ ' usa-current' if item.active }}" href="{{ item.href }}" {% for attribute, value
in item.attributes %} {{attribute}}="{{value}}" {% endfor %}>
<span>{{ item.text }}</span>
</a>
</li>
{% endif %}
{% endfor %}
<!-- <li class="usa-nav__secondary-item">
<a href="javascript:void(0)">
Sign in
</a>
</li> -->
</ul>
<!-- <section aria-label="Search component">
<form class="usa-search usa-search--small margin-bottom-2" role="search">

View File

@@ -0,0 +1,15 @@
# Textarea
## Installation
See the [main README quick start guide](https://github.com/alphagov/govuk-frontend#quick-start) for how to install this component.
## Guidance and Examples
Find out when to use the textarea component in your service in the [GOV.UK Design System](https://design-system.service.gov.uk/components/textarea).
## Component options
Use options to customize the appearance, content and behavior of a component when using a macro, for example, changing the text.
See [options table](https://design-system.service.gov.uk/components/textarea/#options-example-default) for details.

View File

@@ -0,0 +1,85 @@
[
{
"name": "id",
"type": "string",
"required": true,
"description": "The id of the textarea."
},
{
"name": "name",
"type": "string",
"required": true,
"description": "The name of the textarea, which is submitted with the form data."
},
{
"name": "rows",
"type": "string",
"required": false,
"description": "Optional number of textarea rows (default is 5 rows)."
},
{
"name": "value",
"type": "string",
"required": false,
"description": "Optional initial value of the textarea."
},
{
"name": "describedBy",
"type": "string",
"required": false,
"description": "One or more element IDs to add to the `aria-describedby` attribute, used to provide additional descriptive information for screenreader users."
},
{
"name": "label",
"type": "object",
"required": true,
"description": "Options for the label component.",
"isComponent": true
},
{
"name": "hint",
"type": "object",
"required": false,
"description": "Options for the hint component.",
"isComponent": true
},
{
"name": "errorMessage",
"type": "object",
"required": false,
"description": "Options for the errorMessage component (e.g. text).",
"isComponent": true
},
{
"name": "formGroup",
"type": "object",
"required": false,
"description": "Options for the form-group wrapper",
"params": [
{
"name": "classes",
"type": "string",
"required": false,
"description": "Classes to add to the form group (e.g. to show error state for the whole group)"
}
]
},
{
"name": "classes",
"type": "string",
"required": false,
"description": "Classes to add to the textarea."
},
{
"name": "autocomplete",
"type": "string",
"required": false,
"description": "Attribute to [identify input purpose](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html), for instance \"postal-code\" or \"username\". See [autofill](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill) for full list of attributes that can be used."
},
{
"name": "attributes",
"type": "object",
"required": false,
"description": "HTML attributes (for example data attributes) to add to the textarea."
}
]

View File

@@ -0,0 +1,3 @@
{% macro govukTextarea(params) %}
{%- include "./template.njk" -%}
{% endmacro %}

View File

@@ -0,0 +1,44 @@
{% from "../error-message/macro.njk" import usaErrorMessage -%}
{% from "../hint/macro.njk" import usaHint %}
{% from "../label/macro.njk" import usaLabel %}
{#- a record of other elements that we need to associate with the input using
aria-describedby for example hints or error messages -#}
{% set describedBy = params.describedBy if params.describedBy else "" %}
<div class="usa-form-group {%- if params.errorMessage %} usa-form-group--error{% endif %} {%- if params.formGroup.classes %} {{ params.formGroup.classes }}{% endif %}">
{{ usaLabel({
html: params.label.html,
text: params.label.text,
classes: params.label.classes,
isPageHeading: params.label.isPageHeading,
attributes: params.label.attributes,
for: params.id
}) | indent(2) | trim }}
{% if params.hint %}
{% set hintId = params.id + '-hint' %}
{% set describedBy = describedBy + ' ' + hintId if describedBy else hintId %}
{{ usaHint({
id: hintId,
classes: params.hint.classes,
attributes: params.hint.attributes,
html: params.hint.html,
text: params.hint.text
}) | indent(2) | trim }}
{% endif %}
{% if params.errorMessage %}
{% set errorId = params.id + '-error' %}
{% set describedBy = describedBy + ' ' + errorId if describedBy else errorId %}
{{ usaErrorMessage({
id: errorId,
classes: params.errorMessage.classes,
attributes: params.errorMessage.attributes,
html: params.errorMessage.html,
text: params.errorMessage.text,
visuallyHiddenText: params.errorMessage.visuallyHiddenText
}) | indent(2) | trim }}
{% endif %}
<textarea class="usa-textarea {{- ' usa-textarea--error' if params.errorMessage }} {{- ' ' + params.classes if params.classes}}" id="{{ params.id }}" name="{{ params.name }}" rows="{%if params.rows %} {{- params.rows -}} {% else %}5{%endif %}"
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete}}"{% endif %}
{%- for attribute, value in params.attributes %} {{attribute}}="{{value}}"{% endfor %}>{{ params.value }}</textarea>
</div>

View File

@@ -0,0 +1,6 @@
{% macro show_more(url, label, with_border=True) %}
<a
href="{{ url }}"
class="usa-link show-more{% if not with_border %}-no-border{% endif %}"
><span>{{ label }}</span></a>
{% endmacro %}

View File

@@ -1,5 +1,5 @@
{% macro mapping_table(caption='', field_headings=[], field_headings_visible=True, caption_visible=True, equal_length=False, border_visible=False) -%}
<table class="usa-table{{' usa-table--borderless' if not border_visible}} width-full">
{% macro mapping_table(caption='', field_headings=[], field_headings_visible=True, caption_visible=True, equal_length=False) -%}
<table class="usa-table usa-table--borderless width-full">
<caption class="font-body-lg table-heading{{ ' usa-sr-only' if not caption_visible}}">
{{ caption }}
</caption>
@@ -22,10 +22,10 @@
</table>
{%- endmacro %}
{% macro list_table(items, caption='', empty_message='', field_headings=[], field_headings_visible=True, caption_visible=True, equal_length=False, border_visible=False) -%}
{% macro list_table(items, caption='', empty_message='', field_headings=[], field_headings_visible=True, caption_visible=True, equal_length=False) -%}
{% set parent_caller = caller %}
{% call mapping_table(caption, field_headings, field_headings_visible, caption_visible, equal_length, border_visible) %}
{% call mapping_table(caption, field_headings, field_headings_visible, caption_visible, equal_length) %}
{% for item in items %}
{% call row(item.id) %}
{{ parent_caller(item, loop.index + 1) }}
@@ -69,7 +69,7 @@
'table-field-right-aligned' if align == 'right' else
'table-field-center-aligned' if align == 'center' else
'table-field-left-aligned'
%}
%}
{% set border = '' if border else 'table-field-noborder' %}
{% set wrap = 'table-field-wrap-text' if wrap else '' %}
@@ -169,7 +169,7 @@
{% if notification.status|format_notification_status_as_url(notification.notification_type) %}
</a>
{% endif %}
<p class="status-hint margin-0 width-card ">
<p class="status-hint margin-0 width-card-lg">
{{ notification.status|format_notification_status_as_time(
notification.created_at|format_datetime_short,
(notification.updated_at or notification.created_at)|format_datetime_short
@@ -180,42 +180,6 @@
{% endif %}
{% endmacro %}
{% macro notification_carrier_field(notification) %}
<!-- {% set displayed_on_single_line = notification.status in ['created', 'pending', 'sending', 'delivered', 'accepted', 'received'] %} -->
{% if not notification %}
{% call field(align='right') %}{% endcall %}
{% else %}
<!-- {% set status = notification.status|format_notification_status_as_field_status(notification.notification_type) %} -->
{% call field(
align='right'
) %}
<p class="status-hint margin-0 width-card">
{{ notification.carrier}}
</p>
{% if displayed_on_single_line %}</span>{% endif %}
{% endcall %}
{% endif %}
{% endmacro %}
{% macro notification_carrier_message_field(notification) %}
{% if not notification %}
{% call field(align='right') %}{% endcall %}
{% else %}
<!-- {% set status = notification.status|format_notification_status_as_field_status(notification.notification_type) %} -->
{% call field(
status=status,
align='right'
) %}
<p class="status-hint margin-0 width-card">
{{notification.provider_response}}
</p>
{% if displayed_on_single_line %}</span>{% endif %}
{% endcall %}
{% endif %}
{% endmacro %}
{% macro spark_bar_field(
count,
@@ -225,7 +189,7 @@
{% call field(align='right') %}
<span {% if id %}id="{{ id }}"{% endif %} class="spark-bar">
<span class="spark-bar-bar">
{{ '{:,.0f}'.format(count) }}
{{ '{:,.0f}'.format(count) }}
<!-- {% if count == 1 -%}
message sent
{% else -%}

View File

@@ -19,22 +19,17 @@
class="form-group{% if field.errors %} form-group-error{% endif %} {{ extra_form_group_classes }}"
data-module="{% if autofocus %}autofocus{% elif colour_preview %}colour-preview{% endif %}"
>
{% if field.errors %}
<div class="usa-alert usa-alert--error edit-textbox-error-mt" role="alert">
<div class="usa-alert__body">
<h4 class="usa-alert__heading">Error message</h4>
<p class="usa-alert__text" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
</p>
</div>
</div>
{% endif %}
<label class="usa-label" for="{{ field.name }}">
{% if label %}
{{ label }}
{% else %}
{{ field.label.text }}
{% endif %}
{% if field.errors %}
<span class="error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
</span>
{% endif %}
</label>
{% if hint %}
<div class="usa-hint">

View File

@@ -1,12 +1,17 @@
{% macro tick_cross(yes, label, truthy_hint='Can', falsey_hint='Cannot') %}
<li>
{% if yes %}
<li>
<span class="tick-cross-tick">
<span class="usa-sr-only">{{ truthy_hint }}</span>
{{ label}}
</span>
</li>
{% else %}
<span class="tick-cross-cross">
<span class="usa-sr-only">{{ falsey_hint }}</span>
{{ label}}
</span>
{% endif %}
</li>
{% endmacro %}
{% macro tick_cross_done_not_done(yes, label) %}

View File

@@ -0,0 +1,10 @@
{% extends "admin_template.html" %}
{% set mainClasses = "" %}
{% block main %}
{% block beforeContent %}{% endblock %}
<main id="main-content" role="main">
{% block content %}{% endblock %}
</main>
{% endblock %}

View File

@@ -13,12 +13,13 @@
{% if not current_user.has_permissions('view_activity') %}
<li class="usa-sidenav__item"><a class="{{ casework_navigation.is_selected('sent-messages') }}" href="{{ url_for('.view_notifications', service_id=current_service.id, status='sending,delivered,failed') }}">Sent messages</a></li>
{% endif %}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('team-members') }}" href="{{ url_for('.manage_users', service_id=current_service.id) }}">Team members</a></li>
{% if current_user.has_permissions('manage_service', allow_org_user=True) %}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('usage') }}" href="{{ url_for('.usage', service_id=current_service.id) }}">Usage</a></li>
{% endif %}
<!-- {% if current_user.has_permissions('manage_api_keys', 'manage_service') %}
{% if current_user.has_permissions('manage_api_keys', 'manage_service') %}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('settings') }}" href="{{ url_for('.service_settings', service_id=current_service.id) }}">Settings</a></li>
{% endif %} -->
{% endif %}
{% if current_user.has_permissions('manage_api_keys') %}
<!-- <li><a class="usa-link{{ main_navigation.is_selected('api-integration') }}" href="{{ url_for('.api_integration', service_id=current_service.id) }}">API integration</a></li> -->
{% endif %}

View File

@@ -1,3 +1,4 @@
{% from "components/big-number.html" import big_number %}
{% from "components/pill.html" import pill %}
<div class="ajax-block-container">
@@ -5,40 +6,11 @@
<div class="grid-row ">
{% for label, query_param, url, count in counts %}
{% if query_param == 'pending' %}
<div class="grid-col-3">
<span class="big-number-smaller">
<span class="big-number-number">
{% if count is number %}
{% if currency %}
{{ "{}{:,.2f}".format(currency, count) }}
{% else %}
{{ "{:,}".format(count) }}
{% endif %}
{% else %}
{{ count }}
{% endif %}
</span>
<span class="big-number-label">{{ query_param }}</span>
</span>
</div>
<div class="grid-col-3">{{ big_number(count, query_param, smaller=True) }}</div>
{% else %}
<div class="grid-col-3">
<span class="big-number-smaller">
<span class="big-number-number">
{% if count is number %}
{% if currency %}
{{ "{}{:,.2f}".format(currency, count) }}
{% else %}
{{ "{:,}".format(count) }}
{% endif %}
{% else %}
{{ count }}
{% endif %}
</span>
<span class="big-number-label">{{ label }}</span>
</span>
</div>
<div class="grid-col-3">{{ big_number(count, label, smaller=True) }}</div>
{% endif %}
{% endfor %}
</div>
{% else %}

View File

@@ -1,16 +0,0 @@
{% if help %}
{% include 'partials/tour.html' %}
{% else %}
<nav class="nav">
<ul class="usa-sidenav">
{# {% if current_user.has_permissions() %} #}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('settings') }}"
href="{{ url_for('main.service_settings', service_id=current_service.id) }}">General</a></li>
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('user-profile') }}"
href="{{ url_for('main.user_profile', service_id=current_service.id) }}">User profile</a></li>
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('team-members') }}"
href="{{ url_for('main.manage_users', service_id=current_service.id) }}">Team members</a></li>
{# {% endif %} #}
</ul>
</nav>
{% endif %}

View File

@@ -1,34 +0,0 @@
{% extends "admin_template.html" %}
{% block per_page_title %}
{% block service_page_title %}{% endblock %} {{ current_service.name }}
{% endblock %}
{% block main %}
<div class="grid-container">
<div class="grid-row margin-top-5">
{% if help %}
<div class="grid-col-3">
{% else %}
<div class="grid-col-3">
{% endif %}
{% include "settings_nav.html" %}
</div>
{% if help %}
<div class="grid-col-8">
{% else %}
<div class="grid-col-9 padding-left-4">
{% endif %}
{% block beforeContent %}
{% block backLink %}{% endblock %}
{% endblock %}
<main id="main-content" role="main" class="usa-prose site-prose margin-bottom-10">
{% block content %}
{% include 'flash_messages.html' %}
{% block maincolumn_content %}{% endblock %}
{% endblock %}
</main>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
Organization: {% if current_service.organization -%}
{{ current_service.organization.name }}
{%- else -%}
Cant tell (domain is {{ current_user.email_domain }})
{%- endif %}
Service: {{ current_service.name }}
{{ url_for('main.service_dashboard', service_id=current_service.id, _external=True) }}
---
Current branding: {{ current_branding }}
Branding requested: {{ branding_requested }}
{% if detail %}
{{ detail }}
{% endif %}

View File

@@ -0,0 +1,31 @@
{% set service = current_service -%}
{% set organization = service.organization -%}
{% set user = current_user -%}
Service: {{ service.name }}
{{ url_for('main.service_dashboard', service_id=service.id, _external=True) }}
---
Organization type: {{ service.organization_type_label }}
{%- if organization.name %} (organization is {{ organization.name }})
{%- else %} (domain is {{ user.email_domain }})
{%- endif %}.
{%- if organization.request_to_go_live_notes %} {{ organization.request_to_go_live_notes }}{% endif %}
{%- if organization.agreement_signed_by %}
Agreement signed by: {{ organization.agreement_signed_by.email_address }}
{% endif -%}
{%- if organization.agreement_signed_on_behalf_of_email_address -%}
Agreement signed on behalf of: {{ organization.agreement_signed_on_behalf_of_email_address }}
{%- endif %}
Emails in next year: {{ service.volume_email|format_thousands }}
Text messages in next year: {{ service.volume_sms|format_thousands }}
Consent to research: {{ service.consent_to_research|format_yes_no }}
Other live services for that user: {{ user.live_services|format_yes_no }}
Service reply-to address: {{ service.default_email_reply_to_address or "not set" }}
---
Request sent by {{ user.email_address }}
Requesters user page: {{ url_for('main.user_information', user_id=user.id, _external=True) }}

View File

@@ -0,0 +1,5 @@
{{ content }}
{% if current_service -%}
Service: "{{ current_service.name }}"
{{ url_for('main.service_dashboard', service_id=current_service.id, _external=True) }}
{% endif %}

View File

@@ -1,6 +1,6 @@
{% from "components/page-footer.html" import page_footer %}
{% from "components/previous-next-navigation.html" import previous_next_navigation %}
{% from "components/table.html" import list_table, field, text_field, link_field, right_aligned_field_heading, hidden_field_heading, row_heading, notification_status_field, notification_carrier_field, notification_carrier_message_field %}
{% from "components/table.html" import list_table, field, text_field, link_field, right_aligned_field_heading, hidden_field_heading, row_heading, notification_status_field %}
<div class="ajax-block-container" id='pill-selected-item'>
@@ -12,19 +12,17 @@
caption="Recent activity",
caption_visible=False,
empty_message='No messages found &thinsp;(messages are kept for {} days)'.format(limit_days)|safe,
field_headings=['Recipient', 'Status', 'Carrier', 'Carrier Response'],
field_headings=['Recipient', 'Status'],
field_headings_visible=False
) %}
{% call row_heading() %}
<a class="usa-link file-list-filename" href="{{ single_notification_url(notification_id=item.id) }}">{{ item.to.splitlines()|join(', ') if item.to else '' }}</a>
<p class="file-list-hint width-card">
<p class="file-list-hint">
{{ item.preview_of_content }}
</p>
{% endcall %}
{{ notification_status_field(item) }}
{{ notification_carrier_field(item) }}
{{ notification_carrier_message_field(item)}}
{% endcall %}
{% if notifications %}

View File

@@ -10,12 +10,7 @@ Error
{% endblock %}
{% block backLink %}
<!--hide back link for one-off sends because the user never created a csv file-->
{% if recipients|length == 1 and not recipients.allowed_to_send_to and not recipients.missing_column_headers %}
<!--do nothing-->
{% else %}
{{ usaBackLink({ "href": back_link }) }}
{% endif %}
{{ usaBackLink({ "href": back_link }) }}
{% endblock %}
{% block maincolumn_content %}
@@ -135,10 +130,7 @@ Error
{% endcall %}
</div>
<!--hide the upload button and back to top link for one off sends-->
{% if recipients|length == 1 and not recipients.allowed_to_send_to and not recipients.missing_column_headers %}
<!-- do nothing -->
{% else %}
<div class="js-stick-at-top-when-scrolling">
<div class="form-group">
{% if not request.args.from_test %}
@@ -152,7 +144,6 @@ Error
</div>
<a href="#content" class="usa-link back-to-top-link">Back to top</a>
</div>
{% endif %}
{% if not request.args.from_test %}
@@ -219,4 +210,4 @@ recipients.column_headers %}
<h2 class="font-body-lg">Preview of {{ template.name }}</h2>
{{ template|string }}
{% endblock %}
{% endblock %}

View File

@@ -4,6 +4,7 @@
{% from "components/table.html" import mapping_table, row, field, text_field, index_field, hidden_field_heading %}
{% from "components/file-upload.html" import file_upload %}
{% from "components/components/back-link/macro.njk" import usaBackLink %}
{% from "components/components/alert/macro.njk" import usaAlert %}
{% block service_page_title %}
Error

View File

@@ -0,0 +1,151 @@
{% extends "withoutnav_template.html" %}
{% from "components/banner.html" import banner %}
{% block per_page_title %}
Cookies
{% endblock %}
{% block cookie_message %}{% endblock %}
{% block maincolumn_content %}
<div class="grid-row">
<div class="grid-col-8">
<div class="cookie-settings__confirmation banner banner-with-tick" data-cookie-confirmation="true" role="group" tabindex="-1">
<h2 class="banner-title">Your cookie settings were saved</h2>
<a class="govuk_link cookie-settings__prev-page" href="#" data-module="track-click" data-track-category="cookieSettings" data-track-action="Back to previous page">
Go back to the page you were looking at
</a>
</div>
<h1 class="font-body-2xl margin-bottom-3">Cookies</h1>
<p class="summary">
Cookies are small files saved on your phone, tablet, or computer when you visit a website.
</p>
<p>We use cookies to make Notify.gov work and collect information about how you use our service.</p>
<h2 class="font-body-lg" id="essential-cookies">Essential cookies</h2>
<p>
Essential cookies keep your information secure while you use Notify. We do not need to ask permission to use them.
</p>
<table>
<caption class="usa-sr-only">Essential cookies</caption>
<thead>
<tr>
<th>Name</th>
<th>Purpose</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
<tr>
<td>
notify_admin_session
</td>
<td width="50%">
Used to keep you signed in
</td>
<td>
20 hours
</td>
</tr>
<tr>
<td>
cookie_policy
</td>
<td width="50%">
Saves your cookie consent settings
</td>
<td>
1 year
</td>
</tr>
</tbody>
</table>
<h2 class="font-body-lg" id="analytics-cookies">Analytics cookies (optional)</h2>
<p>
With your permission, we use Google Analytics to collect data about how you use Notify. This information helps us to improve our service.
</p>
<p>
Google is not allowed to use or share our analytics data with anyone.
</p>
<p>
Google Analytics stores anonymized information about:
</p>
<ul class="usa-list usa-list--bullet">
<li>how you got to Notify.gov</li>
<li>the pages you visit on Notify and how long you spend on them</li>
<li>any errors you see while using Notify</li>
</ul>
<table>
<caption class="usa-sr-only">Google Analytics cookies</caption>
<thead>
<tr>
<th>Name</th>
<th>Purpose</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
<tr>
<td>
_ga
</td>
<td width="50%">
Checks if youve visited Notify before. This helps us count how many people visit our site.
</td>
<td>
2 years
</td>
</tr>
<tr>
<td>
_gid
</td>
<td width="50%">
Checks if youve visited Notify before. This helps us count how many people visit our site.
</td>
<td>
24 hours
</td>
</tr>
</tbody>
</table>
<div class="cookie-settings__no-js">
<h2 class="govuk-heading-s">Do you want to accept analytics cookies?</h2>
<p>We use Javascript to set our analytics cookies. Unfortunately Javascript is not running on your browser, so you cannot change your settings. You can try:</p>
<ul class="usa-list usa-list--bullet">
<li>reloading the page</li>
<li>turning on Javascript in your browser</li>
</ul>
</div>
<div class="cookie-settings__form-wrapper">
<form data-module="cookie-settings">
<div class="usa-form-group">
<fieldset class="usa-fieldset" aria-describedby="changed-name-hint">
<legend class="usa-fieldset__legend usa-fieldset__legend--s">
Do you want to accept analytics cookies?
</legend>
<div class="usa-radios usa-radios--inline">
<div class="usa-radio">
<input class="usa-radio__input" id="cookies-analytics-yes" name="cookies-analytics" type="radio" value="on">
<label class="usa-radio__label" for="cookies-analytics-yes">
Yes
</label>
</div>
<div class="usa-radio">
<input class="usa-radio__input" id="cookies-analytics-no" name="cookies-analytics" type="radio" value="off">
<label class="usa-radio__label" for="cookies-analytics-no">
No
</label>
</div>
</div>
</fieldset>
</div>
<button class="govuk-button" type="submit">Save cookie settings</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% from "components/table.html" import list_table, field, right_aligned_field_heading, row_heading %}
{% from "components/big-number.html" import big_number -%}
<div class='dashboard-table ajax-block-container'>
{% call(item, row_number) list_table(
@@ -35,78 +36,28 @@
{% endcall %}
{% call field() %}
{% if item.scheduled %}
{% if link %}
<a class="usa-link display-flex" href="{{ link }}">
{% endif %}
<span class="big-number-smallest">
<span class="big-number-number">
{% if item.notification_count is number %}
{% if currency %}
{{ "{}{:,.2f}".format(currency, item.notification_count) }}
{% else %}
{{ "{:,}".format(item.notification_count) }}
{% endif %}
{% else %}
{{ item.notification_count }}
{% endif %}
</span>
{% if item.notification_count %}
<span class="big-number-label">{{ item.notification_count|message_count_label(item.template_type,suffix='waiting to send') }}</span>
{% endif %}
</span>
{% if link %}
</a>
{% endif %}
{{ big_number(
item.notification_count,
smallest=True,
label=item.notification_count|message_count_label(
item.template_type,
suffix='waiting to send'
)
) }}
{% else %}
<div class="grid-row">
<div class="grid-col-4">
{% if link %}
<a class="usa-link display-flex" href="{{ link }}">
{% endif %}
<span class="big-number-smallest">
<span class="big-number-number">
{{ "{:,}".format(item.notifications_sending) }}
</span>
<span class="big-number-label">pending</span>
</span>
{% if link %}
</a>
{% endif %}
{{ big_number(
item.notifications_sending,
smallest=True,
label='pending',
) }}
</div>
<div class="grid-col-4">
<span class="big-number-smallest">
<span class="big-number-number">
{% if item.notifications_delivered is number %}
{{ "{:,}".format(item.notifications_delivered) }}
{% else %}
{{ item.notifications_delivered }}
{% endif %}
</span>
<span class="big-number-label">delivered</span>
</span>
{{ big_number(item.notifications_delivered, smallest=True, label='delivered') }}
</div>
<div class="grid-col-4">
{% if link %}
<a class="usa-link display-flex" href="{{ link }}">
{% endif %}
<span class="big-number-smallest">
<span class="big-number-number">
{% if item.notifications_failed is number %}
{% if currency %}
{{ "{}{:,.2f}".format(currency, item.notifications_failed) }}
{% else %}
{{ "{:,}".format(item.notifications_failed) }}
{% endif %}
{% else %}
{{ item.notifications_failed }}
{% endif %}
</span>
<span class="big-number-label">failed</span>
</span>
{% if link %}
</a>
{% endif %}
{{ big_number(item.notifications_failed, smallest=True, label='failed') }}
</div></div>
{% endif %}

View File

@@ -1,32 +1,18 @@
{% from "components/big-number.html" import big_number_with_status %}
<div class="ajax-block-container">
<div class="grid-row grid-gap">
<div id="total-sms" class="grid-col-12">
<span class="big-number-with-status">
<a class="usa-link display-flex" href="{{ url_for('.view_notifications', service_id=service_id, message_type='sms', status='sending,delivered,failed') }}">
<span class="big-number-smaller">
<span class="big-number-number">
{% if statistics['sms']['requested'] is number %}
{{ "{:,}".format(statistics['sms']['requested']) }}
{% else %}
{{ statistics['sms']['requested'] }}
{% endif %}
</span>
<span class="big-number-label">{{ statistics['sms']['requested']|message_count_label('sms', suffix='sent') }}</span>
</span>
</a>
{% if show_failures %}
<span class="big-number-status{% if statistics['sms']['show_warning'] is sameas true %} big-number-status--failing{% endif %}">
{% if statistics['sms']['failed'] %}
<a class="usa-link" href="{{ url_for('.view_notifications', service_id=service_id, message_type='sms', status='failed') }}">
{{ "{:,}".format(statistics['sms']['failed']) }}
failed {{ statistics['sms']['failed_percentage'] }}%
</a>
{% else %}
No failures
{% endif %}
</span>
{% endif %}
</span>
{{ big_number_with_status(
statistics['sms']['requested'],
statistics['sms']['requested']|message_count_label('sms', suffix='sent'),
statistics['sms']['failed'],
statistics['sms']['failed_percentage'],
statistics['sms']['show_warning'],
failure_link=url_for(".view_notifications", service_id=service_id, message_type='sms', status='failed'),
link=url_for(".view_notifications", service_id=service_id, message_type='sms', status='sending,delivered,failed'),
smaller=True,
) }}
</div>
<!-- <div id="total-email" class="grid-col-6">
<span class="big-number-with-status pilot-disabled">

View File

@@ -1,4 +1,5 @@
{% from "components/table.html" import list_table, field, right_aligned_field_heading, row_heading %}
{% from "components/show-more.html" import show_more %}
<div class="ajax-block-container">
{% if current_service.scheduled_job_stats.count %}

View File

@@ -1,3 +1,5 @@
{% from "components/big-number.html" import big_number %}
<div class='grid-row grid-gap ajax-block-container'>
<div class='grid-col-12'>
@@ -13,21 +15,17 @@
</thead>
<tbody>
<tr>
<td><span class="big-number-smaller big-number-number">{{ "{:,}".format(sms_sent) }}</span></td>
<td>{{ big_number(40000 - sms_allowance_remaining, smaller=True) }}</td>
<td>
{% if sms_cost %}
<span class="big-number-smaller">
<span class="big-number-number">
{{ "{}{:,.2f}".format('$', sms_cost) }}
</span>
<span class="big-number-label">'spent on text messages'</span>
</span>
{{ big_number(
sms_cost,
'spent on text messages',
currency="$",
smaller=True
) }}
{% else %}
<span class="big-number-smaller">
<span class="big-number-number">
{{ "{:,}".format(sms_allowance_remaining) }}
</span>
</span>
{{ big_number(sms_allowance_remaining, smaller=True) }}
{% endif %}
</td>
</tr>

View File

@@ -1,6 +1,7 @@
{% extends "withnav_template.html" %}
{% from "components/table.html" import list_table, field, text_field, link_field, right_aligned_field_heading, hidden_field_heading, row_heading, notification_status_field, notification_carrier_field, notification_carrier_message_field %}
{% from "components/show-more.html" import show_more %}
{% from "components/table.html" import list_table, field, right_aligned_field_heading, hidden_field_heading %}
{% from "components/ajax-block.html" import ajax_block %}
{% block service_page_title %}
@@ -29,73 +30,10 @@
{{ ajax_block(partials, updates_url, 'template-statistics') }}
<h2 class="margin-top-4 margin-bottom-1">Recent Batches</h2>
<div class='job-table'>
<table class="usa-table usa-table--borderless width-full">
<thead class="table-field-headings">
<tr>
<th scope="col" class="table-field-heading-first file-name">
<span>File name</span>
</th>
<th scope="col" class="table-field-heading template">
<span>Template</span>
</th>
<th scope="col" class="table-field-heading time-sent">
<span>Time sent</span>
</th>
<th scope="col" class="table-field-heading sender">
<span>Sender</span>
</th>
<th scope="col" class="table-field-heading #-of-recipients">
<span># of Recipients</span>
</th>
<th scope="col" class="table-field-heading report">
<span>Report</span>
</th>
</tr>
</thead>
<tbody>
{% for notification in notifications[:5] %}
{% if notification %}
<tr class="table-row" id="{{ notification.job.id }}">
<th class="table-field">
{{ notification.job.original_file_name if notification.job.original_file_name else 'Manually entered number'}}
<br>
<a class="usa-link file-list-filename" href="/services/{{ notification.service }}/jobs/{{ notification.job.id }}">View Batch</a>
</th>
<th class="table-field">
{{ notification.template.name }}
</th>
<th class="table-field">
{{ notification.created_at | format_datetime_short_america }}
</th>
<th class="table-field">
{{ notification.created_by.name }}
</th>
{% set job_available = jobs|selectattr('job_id', 'equalto', notification.job.id)|first %}
<th class="table-field">
{{ job_available.notification_count if job_available else ''}}
</th>
<th class="table-field">
{% if job_available and job_available.time_left != "Data no longer available" %}
<a class="usa-link file-list-filename" href="{{ job_available.download_link }}">
{{ "Download" if job_available.job_id else '' }}</a>
<span class="usa-hint">{{ job_available.time_left }}</span>
{% elif job_available %}
<span>{{ job_available.time_left }}</span>
{% endif %}
</th>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
<h2 class="margin-top-4 margin-bottom-1">Usage</h2>
<h3 class="margin-bottom-0">Daily</h3>
<p class="margin-0">Across all services</p>
<table class="usage-table usa-table usa-table--borderless margin-top-1 margin-bottom-5">
<table class="usa-table usa-table--borderless margin-top-1 margin-bottom-5">
<caption class="usa-sr-only">
Daily
</caption>
@@ -107,7 +45,7 @@
</thead>
<tbody>
<tr>
<td>{{ global_message_limit - daily_global_messages_remaining }}</td>
<td>{{ 5000 - daily_global_messages_remaining }}</td>
<td>
{{ daily_global_messages_remaining }}
</td>
@@ -116,12 +54,12 @@
</table>
{% if current_user.has_permissions('manage_service') %}
<h3 class='margin-bottom-0' id="current-year"></h3>
<h3 class='margin-bottom-0'>2023</h3>
{{ ajax_block(partials, updates_url, 'usage') }}
<a
href="{{ url_for('.usage', service_id=current_service['id']) }}"
class="usa-link show-more"
><span>See all usage</span></a>
{{ show_more(
url_for(".usage", service_id=current_service['id']),
'See all usage'
) }}
{% endif %}
</div>

View File

@@ -1,4 +1,5 @@
{% from "components/page-header.html" import page_header %}
{% from "components/big-number.html" import big_number_with_status, big_number %}
{% from "components/pill.html" import pill %}
{% from "components/table.html" import list_table, field, hidden_field_heading, right_aligned_field_heading, row_heading %}
@@ -43,16 +44,11 @@
(month.email_counts, 'email'),
] %}
{% call field(align='left') %}
<span class="big-number-smallest">
<span class="big-number-number">
{% if counts.requested is number %}
{{ "{:,}".format(counts.requested) }}
{% else %}
{{ counts.requested }}
{% endif %}
</span>
<span class="big-number-label">{{ counts.requested|message_count_label(template_type, suffix='') }}</span>
</span>
{{ big_number(
counts.requested,
counts.requested|message_count_label(template_type, suffix=''),
smallest=True,
) }}
{% if counts.requested %}
<span class="{{ 'failure-highlight' if counts.show_warning else '' }}">
{{ "{:,}".format(counts.failed) }} failed

View File

@@ -1,4 +1,7 @@
{% from "components/big-number.html" import big_number %}
{% from "components/big-number.html" import big_number %}
{% from "components/table.html" import list_table, field, right_aligned_field_heading, row_heading, spark_bar_field %}
{% from "components/show-more.html" import show_more %}
<div class="ajax-block-container">
{% if template_statistics|length > 1 %}
@@ -24,10 +27,11 @@
{{ spark_bar_field(item.count, most_used_template_count, id=item.template_id) }}
{% endcall %}
<a
href="{{ url_for('.template_usage', service_id=current_service.id) }}"
class="usa-link show-more-no-border"
><span>See templates used by month</span></a>
{{ show_more(
url_for('.template_usage', service_id=current_service.id),
'See templates used by month',
with_border=False
) }}
</div>
{% endif %}
</div>

View File

@@ -4,6 +4,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/components/alert/macro.njk" import usaAlert %}
{% block service_page_title %}
{{ heading_action }} text message template
@@ -49,7 +50,7 @@
</div>
<div class="grid-row">
<div class="grid-col-12">
<div class="template-content-count" bg-color="red">
<div class="template-content-count">
<div data-module="update-status" data-target="template_content"
data-updates-url="{{ url_for('.count_content_length', service_id=current_service.id, template_type='sms') }}"
aria-live="polite">
@@ -68,7 +69,7 @@
</p>
</div>
</div>
<div class="tablet:grid-col-9 mobile-lg:grid-col-12">
<h2 id="help" class="font-body-xl margin-top-2">How to customize your message</h2>
<div class="usa-accordion usa-accordion--bordered usa-accordion--multiselectable maxw-mobile-lg" data-allow-multiple>

View File

@@ -0,0 +1,46 @@
{% extends "views/platform-admin/_base_template.html" %}
{% from "components/file-upload.html" import file_upload %}
{% from "components/page-header.html" import page_header %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/form.html" import form_wrapper %}
{% from "components/components/back-link/macro.njk" import usaBackLink %}
{% block service_page_title %}
{{ '{} email branding'.format('Update' if email_branding else 'Create')}}
{% endblock %}
{% block backLink %}
{{ usaBackLink({ "href": url_for('.email_branding') }) }}
{% endblock %}
{% block platform_admin_content %}
{{ page_header('{} email branding'.format('Update' if email_branding else 'Add')) }}
<div class="grid-row">
<div class="grid-col-9">
{% if logo %}
<div id="logo-img">
<img src="https://{{ cdn_url }}/{{ logo }}"/>
</div>
{% endif %}
<p>
Logos should be PNG files, 108px high
</p>
{{ file_upload(form.file, allowed_file_extensions=['png'], button_text='{} logo'.format('Update' if email_branding else 'Upload')) }}
{% call form_wrapper() %}
<div class="form-group">
<div style='margin-top:15px;'>{{form.name}}</div>
<div style='margin-top:15px;'>{{form.text}}</div>
{{ form.colour }}
{{ form.brand_type }}
{{ page_footer(
'Save',
button_name='operation',
button_value='email-branding-details'
) }}
</div>
{% endcall %}
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "views/platform-admin/_base_template.html" %}
{% from "components/page-header.html" import page_header %}
{% from "components/live-search.html" import live_search %}
{% from "components/components/button/macro.njk" import usaButton %}
{% set page_title = "Email branding" %}
{% block per_page_title %}
{{ page_title }}
{% endblock %}
{% block platform_admin_content %}
<h1 class="font-body-2xl">{{ page_title }}</h1>
{{ live_search(target_selector='.message-name', show=True, form=search_form, autofocus=True) }}
<nav>
<ul class="email-brand">
{% for brand in email_brandings %}
<li class="message-name">
<a class="usa-link" href="{{ url_for('.update_email_branding', branding_id=brand.id) }}">
{{ brand.name or 'Unnamed' }}
</a>
</li>
{% endfor %}
</ul>
</nav>
<div class="js-stick-at-bottom-when-scrolling">
{{ usaButton({
"element": "a",
"text": "New brand",
"href": url_for('.create_email_branding'),
"classes": "govuk-button--secondary"
}) }}
</div>
{% endblock %}

View File

@@ -13,6 +13,7 @@
<p class="usa-body">If you work for the government, you can use Notify.gov to keep your users updated.</p>
<p class="usa-body">Notify makes it easy to create, customize, and send <a class="usa-link" href="{{ url_for('main.features_sms') }}">text messages</a>.</p>
<!-- <ul class="list list-bullet">
<li><a class="usa-link" href="{{ url_for('main.features_email') }}">emails</a></li>
<li><a class="usa-link" href="{{ url_for('main.features_sms') }}">text messages</a></li>
</ul> -->
<p class="usa-body">You do not need any technical knowledge to use Notify.</p>

View File

@@ -0,0 +1,49 @@
{% extends "content_template.html" %}
{% from "components/table.html" import mapping_table, row, text_field, edit_field, field with context %}
{% block per_page_title %}
Emails
{% endblock %}
{% block content_column_content %}
<h1 class="font-body-2xl">Emails</h1>
<p>Send an unlimited number of emails for free with Notify.gov.</p>
{% if not current_user.is_authenticated %}
<p><a class="usa-link" href="{{ url_for('main.register') }}">Create an account</a> and try Notify for yourself.</p>
{% endif %}
<h2 class="heading heading-medium">Features</h2>
<p>Notify makes it easy to:</p>
<ul class="list list-bullet">
<li>create reusable email templates</li>
<li>personalize the content of your emails</li>
<li>send and schedule bulk messages</li>
</ul>
<p>You can also <a class="usa-link" href="{{ url_for('.documentation') }}">integrate with our API</a> to send emails automatically.</p>
<h3 class="font-body-md margin-bottom-2" id="branding">Email branding</h3>
<p>Add your organizations logo and brand color to email templates.</p>
<p>See <a class="usa-link" href="{{ url_for('.branding_and_customisation', _anchor='email-branding') }}">how to change your email branding</a>.</p>
<h3 class="font-body-md margin-bottom-2" id="send-files">Send files by email</h3>
<p>Notify offers a safe and reliable way to send files by email.</p>
<p>Upload a file using our API, then send your users an email with a link to download it.</p>
<p>Notify uses encrypted links instead of email attachments because:</p>
<ul class="list list-bullet">
<li>theyre more secure</li>
<!--<li>you can track when the recipient downloads your document</li>-->
<li>email attachments are often marked as spam</li>
</ul>
<p>Read our <a class="usa-link" href="{{ url_for('.documentation') }}">API documentation</a> for more information.</p>
<h3 class="font-body-md margin-bottom-2" id="reply-to">Add a reply-to address</h3>
<p>Notify lets you choose the email address that users reply to.</p>
<p>Emails with a reply-to address seem more trustworthy and are less likely to be labelled as spam.</p>
<p>See <a class="usa-link" href="{{ url_for('.branding_and_customisation', _anchor='reply-to-address') }}">how to add a reply-to address</a>.</p>
<h2 class="heading heading-medium">Pricing</h2>
<p>Its free to send emails through Notify.</p>
<p><a class="usa-link" href="{{ url_for('.pricing') }}">See pricing</a> for more details.</p>
{% endblock %}

View File

@@ -9,6 +9,55 @@
{% block content_column_content %}
{{ content | safe }}
<h1 class="font-body-2xl margin-bottom-3">Get started</h1>
<ol class="usa-process-list">
<li class="usa-process-list__item padding-bottom-4 margin-top-2">
<h2 class="usa-process-list__heading line-height-sans-3">Check if Notify.gov is right for you</h2>
<p>Read about our <a class="usa-link" href="{{ url_for('main.features') }}">features</a>, <a class="usa-link" href="{{ url_for('.pricing') }}">pricing</a> and <a class="usa-link" href="{{ url_for('main.roadmap') }}">roadmap</a>.</p>
</li>
<li class="usa-process-list__item padding-bottom-4">
<h2 class="usa-process-list__heading line-height-sans-3">Create an account</h2>
{% if not current_user.is_authenticated %}
<p><a class="usa-link" href="{{ url_for('.register') }}">Create an account</a> for free and add your first Notify service. When you add a new service it will start in <a class="usa-link" href="{{ url_for('main.trial_mode_new') }}">trial mode</a>.</p>
{% else %}
<p>Create an account for free and add your first Notify service. When you add a new service, it will start in <a class="usa-link" href="{{ url_for('main.trial_mode_new') }}">trial mode</a>.</p>
{% endif %}
</li>
<li class="usa-process-list__item padding-bottom-4">
<h2 class="usa-process-list__heading line-height-sans-3">Write some messages</h2>
<p>Add message templates with examples of the content you plan to send. You can use our <a class="usa-link" href="{{ url_for('main.guidance_index') }}">guidance</a> to help you.</p>
</li>
<li class="usa-process-list__item padding-bottom-4">
<h2 class="usa-process-list__heading line-height-sans-3">Set up your service</h2>
{% if not current_user.is_authenticated or not current_service %}
<p>Review your settings to add message branding and sender information.</p>
<p>Add team members and check their permissions.</p>
{% else %}
<p>Review your <a class="usa-link" href="{{ url_for('.service_settings', service_id=current_service.id) }}">settings</a> to add message branding and sender information.</p>
<p>Add <a class="usa-link" href="{{ url_for('.manage_users', service_id=current_service.id) }}">team members</a> and check their permissions.</p>
{% endif %}
</li>
<!-- <li class="get-started-list__item">
<h2 class="usa-process-list__heading line-height-sans-1">Set up an API integration (optional)</h2>
<p>You can use the Notify API to send messages automatically.</p>
<p>Our <a class="usa-link" href="{{ url_for('main.documentation') }}">documentation</a> explains how to integrate the API with a web application or back office system.</p>
</li> -->
<li class="usa-process-list__item padding-bottom-4">
<h2 class="usa-process-list__heading line-height-sans-3">Start sending messages</h2>
{% if not current_user.is_authenticated or not current_service %}
<p>When youre ready to send messages to people outside your team, go to the <b class="bold">Settings</b> page and select <b class="bold">Request to go live</b>. Well approve your request within one working day.</p>
{% else %}
<p>You should <a class="usa-link" href="{{ url_for('.request_to_go_live', service_id=current_service.id) }}">request to go live</a> when youre ready to send messages to people outside your team. Well approve your request within one working day.</p>
{% endif %}
<!-- <p>Check <a class="usa-link" href="{{ url_for('main.how_to_pay') }}">how to pay</a> if youre planning to exceed the <a class="usa-link" href="{{ url_for('.pricing', _anchor='text-messages') }}">free text message allowance</a>.</p> -->
</li>
</ol>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "content_template.html" %}
{% from "components/service-link.html" import service_link %}
{% block per_page_title %}
Branding and customization
{% endblock %}
{% block content_column_content %}
<h1 class="font-body-2xl margin-bottom-3">Branding and customization</h1>
<!-- <p>This page explains how to:</p>
<ul class="list list-bullet">
<li><a class="usa-link" href="#email-branding">change your email branding</a></li>
<li><a class="usa-link" href="#reply-to-address">add a reply-to email address</a></li>
<li><a class="usa-link" href="#text-message-sender">change the text message sender</a></li>
</ul>
<h2 class="font-body-lg" id="email-branding">Change your email branding</h2>
<p>The default branding for email templates is the Notify.govlogo.</p>
<p>To change your email branding:</p>
<ol class="list list-number">
<li>Go to the <b class="bold">Email settings</b> section of the {{ service_link(current_service, 'main.service_settings', 'settings') }} page.</li>
<li>Select <b class="bold">Change</b> on the <b class="bold">Email branding</b> row.</li>
</ol>
<h2 class="font-body-lg" id="reply-to-address">Add a reply-to email address</h2>
<p>You can choose the email address that your users reply to. You must add at least one reply-to address for your service before you can <a class="usa-link" href="{{ url_for('main.trial_mode_new') }}">request to go live</a>.</p>
<p>To add a reply-to email address:</p>
<ol class="list list-number">
<li>Go to the <b class="bold">Email settings</b> section of the {{ service_link(current_service, 'main.service_settings', 'settings') }} page.</li>
<li>Select <b class="bold">Manage</b> on the <b class="bold">Reply-to email addresses</b> row.</li>
<li>Select <b class="bold">Add reply-to address</b>.</li>
</ol> -->
<h2 class="font-body-lg" id="text-message-sender">Change the text message sender</h2>
<p>The text message sender tells your users who the message is from.</p>
<p>To change the text message sender from the default of Notify.gov:</p>
<ol class="list list-number">
<li>Go to the <b class="bold">Text message settings</b> section of the {{ service_link(current_service, 'main.service_settings', 'settings') }} page.</li>
<li>Select <b class="bold">Manage</b> on the <b class="bold">Text message senders</b> row.</li>
<li>Select <b class="bold">Change</b> or <b class="bold">Add text message sender</b>.</li>
</ol>
{% endblock %}

View File

@@ -17,12 +17,12 @@
<ul class="list list-bullet">
<li><a class="usa-link" href="#editandformat">edit and format messages</a></li>
<li><a class="usa-link" href="#customization">add customization</a></li>
<li><a class="usa-link" href="#branding">add branding and customization</a></li>
<!-- <li><a class="usa-link" href="{{ url_for('.send_files_by_email') }}">send files by email</a></li> -->
</ul>
<h2 class="font-body-lg" id="#formatting">Edit and format messages</h2>
<p>This section explains how to:</p>
<ul class="list list-bullet">
<li><a class="usa-link" href="#formatting">format your content</a></li>
@@ -30,42 +30,42 @@
<li><a class="usa-link" href="#personalised-content">personalize your content</a></li>
<li><a class="usa-link" href="#optional-content">add optional content</a></li>
</ul>
<h3 class="font-body-lg" id="formatting">Format your content</h3>
<p>You can see a list of formatting instructions on the edit template page:</p>
<ol class="list list-number">
<li>Go to the {{ service_link(current_service, 'main.choose_template', 'templates') }} page.</li>
<li>Add a new template or choose an existing template and select <b class="bold">Edit</b>.</li>
</ol>
<h3 class="font-body-lg" id="links">Add links</h3>
<p>When composing a text message, write URLs in full and Notify will convert them into links for you.
</p>
<p>You cannot convert text into a link.
<!-- Research shows that people like to check a URL looks genuine before clicking the link in an email. This is hard to do if the URL is hidden behind clickable link text.-->
</p>
<p>We do not recommend using a third-party link shortening service because:</p>
<ul class="list list-bullet">
<li>your users cannot see where the link will take them</li>
<li>your link might stop working if theres a service outage</li>
<li>you can no longer control where the redirect goes</li>
</ul>
<h3 class="font-body-lg" id="personalised-content">Personalize your content</h3>
<p>To personalize the content of your messages, add a placeholder to the template.</p>
<p>Placeholders are filled in with details, like a name or reference number, each time you send a
message.</p>
<p>To add a placeholder to the template:</p>
<ol class="list list-number">
<li>Go to the {{ service_link(current_service, 'main.choose_template', 'templates') }} page.</li>
<li>Add a new template or choose an existing template and select <b class="bold">Edit</b>.</li>
@@ -73,20 +73,20 @@
((ref&nbsp;number)).</li>
<li>Select <b class="bold">Save</b>.</li>
</ol>
<p>When you send a message you can either:</p>
<ul class="list list-bullet">
<li>manually fill in the placeholders yourself</li>
<li>upload a list of personal details and let Notify do it for you</li>
</ul>
<p>If you upload a list, the column names need to match the placeholders in your template.</p>
<h3 class="font-body-lg" id="optional-content">Add optional content</h3>
<p>To add optional content to your messages:</p>
<ol class="list list-number">
<li>Go to the {{ service_link(current_service, 'main.choose_template', 'templates') }} page.</li>
<li>Add a new template or choose an existing template and select <b class="bold">Edit</b>.</li>
@@ -94,25 +94,25 @@
who are under 18: ((under18??Please get your application signed by a parent or guardian.))</li>
<li>Select <b class="bold">Save</b>.</li>
</ol>
<p>For each person you send this message to, specify yes or no to show or hide this content. You
can either:</p>
<ul class="list list-bullet">
<li>do this yourself</li>
<li>upload a list of personal details and let Notify do it for you</li>
</ul>
<p>If you upload a list, the column names need to match the optional content in your template.</p>
<h2 class="font-body-lg" id="customization">Message customization</h2>
<h2 class="font-body-lg" id="branding">Branding and customization</h2>
<h3 class="font-body-lg">Change the text message sender</h3>
<p>The text message sender tells your users who the message is from.</p>
<p>To change the text message sender from the default of Notify.gov:</p>
<ol class="list list-number">
<li>Go to the <b class="bold">Text message settings</b> section of the {{
service_link(current_service, 'main.service_settings', 'settings') }} page.</li>

Some files were not shown because too many files have changed in this diff Show More