diff --git a/.gitignore b/.gitignore
index 508249609..9b8b7c764 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,7 +62,6 @@ target/
.vscode
# Frontend dependencies and compiled assets
-app/assets/stylesheets/govuk_template/.sass-cache/
.sass-cache/
cache/
node_modules
diff --git a/Makefile b/Makefile
index 17cfe64fa..3e5415b44 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,6 @@ GIT_COMMIT ?= $(shell git rev-parse HEAD 2> /dev/null || echo "")
DOCKER_IMAGE_TAG := $(shell cat docker/VERSION)
DOCKER_BUILDER_IMAGE_NAME = govuk/notify-admin-builder:${DOCKER_IMAGE_TAG}
-DOCKER_TTY ?= $(if ${JENKINS_HOME},,t)
BUILD_TAG ?= notifications-admin-manual
BUILD_NUMBER ?= 0
@@ -27,39 +26,13 @@ $(eval export CF_HOME)
NOTIFY_CREDENTIALS ?= ~/.notify-credentials
+
+## DEVELOPMENT
+
.PHONY: help
help:
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
-.PHONY: check-env-vars
-check-env-vars: ## Check mandatory environment variables
- $(if ${DEPLOY_ENV},,$(error Must specify DEPLOY_ENV))
- $(if ${DNS_NAME},,$(error Must specify DNS_NAME))
-
-.PHONY: sandbox
-sandbox: ## Set environment to sandbox
- $(eval export DEPLOY_ENV=sandbox)
- $(eval export DNS_NAME="cloudapps.digital")
- @true
-
-.PHONY: preview
-preview: ## Set environment to preview
- $(eval export DEPLOY_ENV=preview)
- $(eval export DNS_NAME="notify.works")
- @true
-
-.PHONY: staging
-staging: ## Set environment to staging
- $(eval export DEPLOY_ENV=staging)
- $(eval export DNS_NAME="staging-notify.works")
- @true
-
-.PHONY: production
-production: ## Set environment to production
- $(eval export DEPLOY_ENV=production)
- $(eval export DNS_NAME="notifications.service.gov.uk")
- @true
-
.PHONY: dependencies
dependencies: ## Install build dependencies
npm set progress=false
@@ -76,22 +49,6 @@ build: dependencies generate-version-file ## Build project
npm run build
pip install -r requirements.txt
-.PHONY: build-paas-artifact
-build-paas-artifact: ## Build the deploy artifact for PaaS
- rm -rf target
- mkdir -p target
- zip -y -q -r -x@deploy-exclude.lst target/notifications-admin.zip ./
-
-.PHONY: upload-paas-artifact ## Upload the deploy artifact for PaaS
-upload-paas-artifact:
- $(if ${DEPLOY_BUILD_NUMBER},,$(error Must specify DEPLOY_BUILD_NUMBER))
- $(if ${JENKINS_S3_BUCKET},,$(error Must specify JENKINS_S3_BUCKET))
- aws s3 cp --region eu-west-1 --sse AES256 target/notifications-admin.zip s3://${JENKINS_S3_BUCKET}/build/notifications-admin/${DEPLOY_BUILD_NUMBER}.zip
-
-.PHONY: upload-static ## Upload the static files to be served from S3
-upload-static:
- aws s3 cp --region eu-west-1 --recursive --cache-control max-age=315360000,immutable ./app/static s3://${DNS_NAME}-static
-
.PHONY: test
test: ## Run tests
./scripts/run_tests.sh
@@ -124,7 +81,7 @@ prepare-docker-build-image: ## Prepare the Docker builder image
make -C docker build
define run_docker_container
- @docker run -i${DOCKER_TTY} --rm \
+ @docker run -it --rm \
--name "${DOCKER_CONTAINER_PREFIX}-${1}" \
-v "`pwd`:/var/project" \
-e UID=$(shell id -u) \
@@ -167,6 +124,32 @@ clean-docker-containers: ## Clean up any remaining docker containers
clean:
rm -rf node_modules cache target
+
+## DEPLOYMENT
+
+.PHONY: check-env-vars
+check-env-vars: ## Check mandatory environment variables
+ $(if ${DEPLOY_ENV},,$(error Must specify DEPLOY_ENV))
+ $(if ${DNS_NAME},,$(error Must specify DNS_NAME))
+
+.PHONY: preview
+preview: ## Set environment to preview
+ $(eval export DEPLOY_ENV=preview)
+ $(eval export DNS_NAME="notify.works")
+ @true
+
+.PHONY: staging
+staging: ## Set environment to staging
+ $(eval export DEPLOY_ENV=staging)
+ $(eval export DNS_NAME="staging-notify.works")
+ @true
+
+.PHONY: production
+production: ## Set environment to production
+ $(eval export DEPLOY_ENV=production)
+ $(eval export DNS_NAME="notifications.service.gov.uk")
+ @true
+
.PHONY: cf-login
cf-login: ## Log in to Cloud Foundry
$(if ${CF_USERNAME},,$(error Must specify CF_USERNAME))
@@ -188,6 +171,10 @@ generate-manifest:
--format=yaml \
<(${DECRYPT_CMD} ${NOTIFY_CREDENTIALS}/credentials/${CF_SPACE}/paas/environment-variables.gpg) 2>&1
+.PHONY: upload-static ## Upload the static files to be served from S3
+upload-static:
+ aws s3 cp --region eu-west-1 --recursive --cache-control max-age=315360000,immutable ./app/static s3://${DNS_NAME}-static
+
.PHONY: cf-deploy
cf-deploy: ## Deploys the app to Cloud Foundry
$(if ${CF_SPACE},,$(error Must specify CF_SPACE))
@@ -196,7 +183,7 @@ cf-deploy: ## Deploys the app to Cloud Foundry
cf v3-cancel-zdt-push ${CF_APP} || true
cf v3-apply-manifest ${CF_APP} -f <(make -s generate-manifest)
- cf v3-zdt-push ${CF_APP} --wait-for-deploy-complete # fails after 5 mins if deploy doesn't work
+ CF_STARTUP_TIMEOUT=10 cf v3-zdt-push ${CF_APP} --wait-for-deploy-complete # fails after 5 mins if deploy doesn't work
.PHONY: cf-deploy-prototype
cf-deploy-prototype: cf-target ## Deploys the first prototype to Cloud Foundry
@@ -210,10 +197,6 @@ cf-deploy-prototype-2: cf-target ## Deploys the second prototype to Cloud Foundr
cf-rollback: cf-target ## Rollbacks the app to the previous release
cf v3-cancel-zdt-push ${CF_APP}
-.PHONY: cf-push
-cf-push:
- cf push -f <(make -s generate-manifest)
-
.PHONY: cf-target
cf-target: check-env-vars
@cf target -o ${CF_ORG} -s ${CF_SPACE}
diff --git a/README.md b/README.md
index c9eeb68ae..e175275dc 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,3 @@
-[](https://requires.io/github/alphagov/notifications-admin/requirements/?branch=master)
-[](https://coveralls.io/github/alphagov/notifications-admin?branch=master)
-
-
# notifications-admin
GOV.UK Notify admin application.
diff --git a/app/__init__.py b/app/__init__.py
index 4daf81b80..759b82b11 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -1,11 +1,11 @@
-import itertools
import os
+import re
import urllib
from datetime import datetime, timedelta, timezone
from functools import partial
from time import monotonic
-import ago
+import humanize
import jinja2
from flask import (
Markup,
@@ -13,6 +13,7 @@ from flask import (
flash,
g,
make_response,
+ redirect,
render_template,
request,
session,
@@ -59,9 +60,11 @@ from app.navigation import (
MainNavigation,
OrgNavigation,
)
+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.contact_list_api_client import contact_list_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
@@ -89,6 +92,7 @@ from app.url_converters import (
LetterFileExtensionConverter,
SimpleDateTypeConverter,
TemplateTypeConverter,
+ TicketTypeConverter,
)
from app.utils import format_thousands, get_logo_cdn_domain, id_safe
@@ -138,6 +142,7 @@ def create_app(application):
# API clients
api_key_api_client,
billing_api_client,
+ contact_list_api_client,
complaint_api_client,
email_branding_client,
events_api_client,
@@ -223,6 +228,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['letter_file_extension'] = LetterFileExtensionConverter
application.url_map.converters['simple_date'] = SimpleDateTypeConverter
@@ -291,7 +297,7 @@ def format_time_24h(date):
return utc_string_to_aware_gmt_datetime(date).strftime('%H:%M')
-def get_human_day(time):
+def get_human_day(time, date_prefix=''):
# Add 1 minute to transform 00:00 into ‘midnight today’ instead of ‘midnight tomorrow’
date = (utc_string_to_aware_gmt_datetime(time) - timedelta(minutes=1)).date()
@@ -304,8 +310,15 @@ def get_human_day(time):
if date == (now - timedelta(days=1)).date():
return 'yesterday'
if date.strftime('%Y') != now.strftime('%Y'):
- return '{} {}'.format(_format_datetime_short(date), date.strftime('%Y'))
- return _format_datetime_short(date)
+ return '{} {} {}'.format(
+ date_prefix,
+ _format_datetime_short(date),
+ date.strftime('%Y'),
+ ).strip()
+ return '{} {}'.format(
+ date_prefix,
+ _format_datetime_short(date),
+ ).strip()
def format_time(date):
@@ -334,10 +347,29 @@ def format_date_human(date):
return get_human_day(date)
+def format_datetime_human(date, date_prefix=''):
+ return '{} at {}'.format(
+ get_human_day(date, date_prefix='on'),
+ format_time(date),
+ )
+
+
+def format_day_of_week(date):
+ return utc_string_to_aware_gmt_datetime(date).strftime('%A')
+
+
def _format_datetime_short(datetime):
return datetime.strftime('%d %B').lstrip('0')
+def naturaltime_without_indefinite_article(date):
+ return re.sub(
+ 'an? (.*) ago',
+ lambda match: '1 {} ago'.format(match.group(1)),
+ humanize.naturaltime(date),
+ )
+
+
def format_delta(date):
delta = (
datetime.now(timezone.utc)
@@ -348,12 +380,17 @@ def format_delta(date):
return "just now"
if delta < timedelta(seconds=60):
return "in the last minute"
- return ago.human(
- delta,
- future_tense='{} from now', # No-one should ever see this
- past_tense='{} ago',
- precision=1
- )
+ return naturaltime_without_indefinite_article(delta)
+
+
+def format_delta_days(date):
+ now = datetime.now(timezone.utc)
+ date = utc_string_to_aware_gmt_datetime(date)
+ if date.strftime('%Y-%M-%D') == now.strftime('%Y-%M-%D'):
+ return "today"
+ if date.strftime('%Y-%M-%D') == (now - timedelta(days=1)).strftime('%Y-%M-%D'):
+ return "yesterday"
+ return naturaltime_without_indefinite_article(now - date)
def valid_phone_number(phone_number):
@@ -584,8 +621,10 @@ def useful_headers_after_request(response):
def register_errorhandlers(application): # noqa (C901 too complex)
- def _error_response(error_code):
- resp = make_response(render_template("error/{0}.html".format(error_code)), error_code)
+ def _error_response(error_code, error_page_template=None):
+ if error_page_template is None:
+ error_page_template = error_code
+ resp = make_response(render_template("error/{0}.html".format(error_page_template)), error_code)
return useful_headers_after_request(resp)
@application.errorhandler(HTTPError)
@@ -596,15 +635,10 @@ def register_errorhandlers(application): # noqa (C901 too complex)
error.message
))
error_code = error.status_code
- if error_code == 400:
- if isinstance(error.message, str):
- msg = [error.message]
- else:
- msg = list(itertools.chain(*[error.message[x] for x in error.message.keys()]))
- resp = make_response(render_template("error/400.html", message=msg))
- return useful_headers_after_request(resp)
- elif error_code not in [401, 404, 403, 410]:
- # probably a 500 or 503
+ if error_code not in [401, 404, 403, 410]:
+ # probably a 500 or 503.
+ # it might be a 400, which we should handle as if it's an internal server error. If the API might
+ # legitimately return a 400, we should handle that within the view or the client that calls it.
application.logger.exception("API {} failed with status {} message {}".format(
error.response.url if error.response else 'unknown',
error.status_code,
@@ -614,8 +648,10 @@ def register_errorhandlers(application): # noqa (C901 too complex)
return _error_response(error_code)
@application.errorhandler(400)
- def handle_400(error):
- return _error_response(400)
+ def handle_client_error(error):
+ # This is tripped if we call `abort(400)`.
+ application.logger.exception('Unhandled 400 client error')
+ return _error_response(400, error_page_template=500)
@application.errorhandler(410)
def handle_gone(error):
@@ -658,19 +694,11 @@ def register_errorhandlers(application): # noqa (C901 too complex)
u'csrf.invalid_token: Aborting request, user_id: {user_id}',
extra={'user_id': session['user_id']})
- resp = make_response(render_template(
- "error/400.html",
- message=['Something went wrong, please go back and try again.']
- ), 400)
- return useful_headers_after_request(resp)
+ return _error_response(400, error_page_template=500)
@application.errorhandler(405)
- def handle_405(error):
- resp = make_response(render_template(
- "error/400.html",
- message=['Something went wrong, please go back and try again.']
- ), 405)
- return useful_headers_after_request(resp)
+ def handle_method_not_allowed(error):
+ return _error_response(405, error_page_template=500)
@application.errorhandler(WerkzeugHTTPException)
def handle_http_error(error):
@@ -680,6 +708,11 @@ def register_errorhandlers(application): # noqa (C901 too complex)
return _error_response(error.code)
+ @application.errorhandler(InviteTokenError)
+ def handle_bad_invite_token(error):
+ flash(str(error))
+ return redirect(url_for('main.sign_in'))
+
@application.errorhandler(500)
@application.errorhandler(Exception)
def handle_bad_request(error):
@@ -742,8 +775,11 @@ def add_template_filters(application):
format_date_human,
format_date_normal,
format_date_short,
+ format_datetime_human,
format_datetime_relative,
+ format_day_of_week,
format_delta,
+ format_delta_days,
format_notification_status,
format_notification_type,
format_notification_status_as_time,
diff --git a/app/assets/error_pages/5xx.html b/app/assets/error_pages/5xx.html
index 1f802e608..8fd9ef8bd 100644
--- a/app/assets/error_pages/5xx.html
+++ b/app/assets/error_pages/5xx.html
@@ -75,7 +75,7 @@
Try again later.
- You can check our system status page to see if there are any known issues. To report a problem, email notify-support@digital.cabinet-office.gov.uk
+ You can check our system status page to see if there are any known issues. To report a problem, email notify-support@digital.cabinet-office.gov.uk
diff --git a/app/assets/javascripts/analytics/analytics.js b/app/assets/javascripts/analytics/analytics.js
index 711f6ec80..a776f93a2 100644
--- a/app/assets/javascripts/analytics/analytics.js
+++ b/app/assets/javascripts/analytics/analytics.js
@@ -15,6 +15,7 @@
window.ga('set', 'anonymizeIp', config.anonymizeIp);
window.ga('set', 'allowAdFeatures', config.allowAdFeatures);
window.ga('set', 'transport', config.transport);
+ window.ga('set', 'title', 'GOV.UK Notify');
};
diff --git a/app/assets/javascripts/enhancedTextbox.js b/app/assets/javascripts/enhancedTextbox.js
index 8adb210f8..a4965857a 100644
--- a/app/assets/javascripts/enhancedTextbox.js
+++ b/app/assets/javascripts/enhancedTextbox.js
@@ -27,6 +27,8 @@
`))
.on("input", this.update);
+ $(window).on("resize", this.resize);
+
visibleTextbox = this.$textbox.clone().appendTo("body").css({
position: 'absolute',
visibility: 'hidden',
@@ -47,7 +49,7 @@
this.resize = () => {
- this.$background.width(this.$textbox.outerWidth());
+ this.$background.width(this.$textbox.width());
this.$textbox.height(
Math.max(
diff --git a/app/assets/javascripts/previewPane.js b/app/assets/javascripts/previewPane.js
index 514b02274..2dac47421 100644
--- a/app/assets/javascripts/previewPane.js
+++ b/app/assets/javascripts/previewPane.js
@@ -10,7 +10,7 @@
branding_style = branding_style.val();
- const $paneWrapper = $('
');
+ const $paneWrapper = $('
');
const $form = $('form');
const previewType = $form.data('previewType');
const $previewPane = $(``);
@@ -28,7 +28,7 @@
}
$paneWrapper.append($previewPane);
- $form.find('.grid-row').eq(0).prepend($paneWrapper);
+ $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');
diff --git a/app/assets/javascripts/templateFolderForm.js b/app/assets/javascripts/templateFolderForm.js
index f81a9eabe..22a7f1a68 100644
--- a/app/assets/javascripts/templateFolderForm.js
+++ b/app/assets/javascripts/templateFolderForm.js
@@ -114,7 +114,7 @@
this.makeButton = (text, opts) => {
let $btn = $(' ')
.html(text)
- .addClass('js-cancel')
+ .addClass('govuk-link govuk-link--no-visited-state js-cancel')
// isn't set if cancelSelector is undefined
.data('target', opts.cancelSelector || undefined)
.attr('tabindex', '0')
diff --git a/app/assets/stylesheets/_grids.scss b/app/assets/stylesheets/_grids.scss
index 85551da03..bf89c79e9 100644
--- a/app/assets/stylesheets/_grids.scss
+++ b/app/assets/stylesheets/_grids.scss
@@ -1,57 +1,50 @@
-.column-whole {
- @include grid-column(1/1);
+
+.govuk-grid-column-one-sixth {
+ @include govuk-grid-column(one-sixth, $class: false);
}
-.column-three-quarters {
- @include grid-column(3/4);
+.govuk-grid-column-five-sixths {
+ @include govuk-grid-column(five-sixths, $class: false);
}
-.column-one-sixth {
- @include grid-column(1/6);
+.govuk-grid-column-one-eighth {
+ @include govuk-grid-column(one-eighth, $class: false);
}
-.column-five-sixths {
- @include grid-column(5/6);
+.govuk-grid-column-five-eighths {
+ @include govuk-grid-column(five-eighths, $class: false);
}
-.column-one-eighth {
- @include grid-column(1/8);
-}
-
-.column-five-eighths {
- @include grid-column(5/8);
-}
-
-.column-seven-eighths {
- @include grid-column(7/8);
+.govuk-grid-column-seven-eighths {
+ @include govuk-grid-column(seven-eighths, $class: false);
}
%top-gutter,
.top-gutter {
@extend %contain-floats;
display: block;
- margin-top: $gutter;
+ margin-top: govuk-spacing(6);
clear: both;
}
.top-gutter-4-3 {
@extend %top-gutter;
- margin-top: $gutter * 4 / 3;
+ margin-top: govuk-spacing(7);
}
.top-gutter-1-2 {
@extend %top-gutter;
- margin-top: $gutter-half;
+ margin-top: govuk-spacing(3);
}
.top-gutter-1-3 {
@extend %top-gutter;
- margin-top: $gutter / 3;
+ margin-top: govuk-spacing(2);
}
.top-gutter-2-3 {
@extend %top-gutter;
- margin-top: $gutter * 2 / 3;
+ margin-top: govuk-spacing(4);
}
.top-gutter-0 {
@@ -61,41 +54,41 @@
%bottom-gutter,
.bottom-gutter {
@extend %contain-floats;
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
clear: both;
}
.bottom-gutter-2-3 {
@extend %bottom-gutter;
- margin-bottom: $gutter-two-thirds;
+ margin-bottom: govuk-spacing(4);
}
.bottom-gutter-1-2 {
@extend %bottom-gutter;
- margin-bottom: $gutter-half;
+ margin-bottom: govuk-spacing(3);
}
.bottom-gutter-1-3 {
@extend %bottom-gutter;
- margin-bottom: $gutter/3;
+ margin-bottom: govuk-spacing(2);
}
.bottom-gutter-3-2 {
@extend %bottom-gutter;
- margin-bottom: $gutter * 3/2;
+ margin-bottom: govuk-spacing(6) * 3/2;
}
.bottom-gutter-2 {
@extend %bottom-gutter;
- margin-bottom: $gutter * 2;
+ margin-bottom: govuk-spacing(9);
}
.left-gutter {
- padding-left: $gutter;
+ padding-left: govuk-spacing(6);
}
.left-gutter-4-3 {
- padding-left: $gutter * 4 / 3;
+ padding-left: govuk-spacing(7);
}
.align-with-heading {
@@ -118,21 +111,5 @@
}
.align-with-big-number-hint {
- margin-top: $gutter / 0.6;
-}
-
-.global-cookie-message {
- p {
- @extend %site-width-container;
- }
-}
-
-.footer-nav {
- @include copy-16;
- margin-bottom: $gutter-two-thirds;
-
- a {
- display: inline-block;
- margin-right: $gutter-half;
- }
+ margin-top: govuk-spacing(8);
}
diff --git a/app/assets/stylesheets/app.scss b/app/assets/stylesheets/app.scss
index c195d3eed..1135d69ab 100644
--- a/app/assets/stylesheets/app.scss
+++ b/app/assets/stylesheets/app.scss
@@ -1,10 +1,4 @@
// Extra CSS overlaying elements
-a {
- &:visited {
- color: $link-colour;
- }
-}
-
.form-control-1-1 {
width: 100%;
}
@@ -23,7 +17,7 @@ a {
.heading-large,
> .heading-medium {
- margin: $gutter-half 0 ($gutter / 3 * 2) 0;
+ margin: govuk-spacing(3) 0 govuk-spacing(4) 0;
word-wrap: break-word;
&.top-gutter-0 {
@@ -61,7 +55,7 @@ td {
}
.heading-medium {
- margin-top: $gutter;
+ margin-top: govuk-spacing(6);
}
.form-label {
@@ -92,12 +86,12 @@ td {
details summary {
text-decoration: underline;
- margin-bottom: $gutter-half;
+ margin-bottom: govuk-spacing(3);
}
.spreadsheet {
- margin-bottom: -$gutter;
+ margin-bottom: -1 * govuk-spacing(6);
.table {
margin-bottom: 0;
@@ -193,10 +187,6 @@ details .arrow {
margin-top: 5px;
}
-#content.override-elements-content {
- padding-bottom: 0;
-}
-
.multiple-choice input:disabled+label {
opacity: 1;
color: $secondary-text-colour;
@@ -218,5 +208,5 @@ details .arrow {
}
.heading-upcoming-jobs {
- margin-top: $gutter-half;
+ margin-top: govuk-spacing(3);
}
diff --git a/app/assets/stylesheets/components/banner.scss b/app/assets/stylesheets/components/banner.scss
index dac7613d4..5cbe60a45 100644
--- a/app/assets/stylesheets/components/banner.scss
+++ b/app/assets/stylesheets/components/banner.scss
@@ -5,8 +5,8 @@
@include bold-19;
color: $button-colour;
display: block;
- padding: $gutter-half;
- margin: $gutter-half 0 $gutter 0;
+ padding: govuk-spacing(3);
+ margin: govuk-spacing(3) 0 govuk-spacing(6) 0;
text-align: left;
position: relative;
clear: both;
@@ -28,11 +28,11 @@
%banner-with-tick,
.banner-with-tick {
- padding: $gutter-half ($gutter + $gutter-half);
+ padding: govuk-spacing(3) (govuk-spacing(6) + govuk-spacing(3));
background-image: file-url('tick.png');
background-size: 19px;
background-repeat: no-repeat;
- background-position: $gutter-half $gutter-half;
+ background-position: govuk-spacing(3) govuk-spacing(3);
}
@@ -55,19 +55,6 @@
outline: 3px solid $yellow;
}
- a {
-
- &:link,
- &:visited {
- color: $error-colour;
- }
-
- &:hover {
- color: $mellow-red;
- }
-
- }
-
.list {
margin-bottom: 0;
}
@@ -81,7 +68,7 @@
color: $white;
margin-top: 10px;
margin-bottom: 0;
- padding: $gutter;
+ padding: govuk-spacing(6);
height: 425px;
overflow: hidden;
box-shadow: inset 0 -1em 1.6em 0 rgba(0, 0, 0, 0.05);
@@ -94,40 +81,40 @@
p {
margin-top: 0;
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
&:last-child {
margin-bottom: 0;
}
& + p {
- margin-top: -$gutter-half;
+ margin-top: -1 * govuk-spacing(3);
}
}
a {
- @include bold-19;
+ font-weight: bold;
display: block;
padding: 0 ;
- margin: 0 0 $gutter 0;
+ margin: 0 0 govuk-spacing(6) 0;
&:link,
&:visited {
color: $white;
}
- &:hover,
+ &:focus,
&:active {
- background-color: $link-hover-colour;
- outline: 10px solid $link-hover-colour;
+ color: $govuk-focus-text-colour;
+ outline: 10px solid $yellow;
}
- &:active,
- &:focus {
- background-color: $yellow;
- outline: 10px solid $yellow;
+ &:hover {
+ color: $white;
+ background-color: $link-hover-colour;
+ outline: 10px solid $link-hover-colour;
}
}
@@ -146,15 +133,15 @@
display: flex;
align-items: baseline;
flex-wrap: wrap;
- padding: ($gutter-half - 1px) 0 ($gutter-half + 1px) 0;
+ padding: (govuk-spacing(3) - 1px) 0 (govuk-spacing(3) + 1px) 0;
border-top: 1px solid $border-colour;
border-bottom: 1px solid $border-colour;
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
text-decoration: none;
&:focus {
border-top: 1px solid transparent;
- border-bottom: 1px solid transparent;
+ border-bottom: 1px solid transparent;
}
&-count,
@@ -188,7 +175,7 @@
}
& + .banner-dashboard {
- margin-top: -$gutter;
+ margin-top: -1 * govuk-spacing(6);
border-top: none;
}
}
diff --git a/app/assets/stylesheets/components/big-number.scss b/app/assets/stylesheets/components/big-number.scss
index 7e4fa81a8..bed587724 100644
--- a/app/assets/stylesheets/components/big-number.scss
+++ b/app/assets/stylesheets/components/big-number.scss
@@ -18,7 +18,7 @@
.big-number-dark {
@extend %big-number;
- padding: $gutter-half;
+ padding: govuk-spacing(3);
position: relative;
background: $black;
color: $white;
@@ -52,11 +52,11 @@
@extend %big-number;
position: relative;
- margin-bottom: $gutter-two-thirds;
+ margin-bottom: govuk-spacing(4);
.big-number,
.big-number-smaller {
- padding: $gutter-half;
+ padding: govuk-spacing(3);
position: relative;
background: $black;
color: $white;
@@ -130,7 +130,6 @@
&:active,
&:focus {
color: $black;
- border-bottom: 1px solid $black;
}
}
@@ -147,12 +146,12 @@
.big-number-meta-wrapper {
position: relative;
- margin: $gutter-half 0 $gutter 0;
+ margin: govuk-spacing(3) 0 govuk-spacing(6) 0;
background: $govuk-blue;
.big-number-meta {
- padding: ($gutter / 3) $gutter-half;
+ padding: govuk-spacing(2) govuk-spacing(3);
color: $white;
pointer-events: none;
diff --git a/app/assets/stylesheets/components/browse-list.scss b/app/assets/stylesheets/components/browse-list.scss
index bc63bd9a6..9b1647110 100644
--- a/app/assets/stylesheets/components/browse-list.scss
+++ b/app/assets/stylesheets/components/browse-list.scss
@@ -1,11 +1,11 @@
.browse-list {
- margin-bottom: $gutter-half;
+ margin-bottom: govuk-spacing(3);
.browse-sub-list {
- margin-top: $gutter-half;
- margin-left: $gutter;
+ margin-top: govuk-spacing(3);
+ margin-left: govuk-spacing(6);
@include media('desktop') {
- margin-left: $gutter * 2;
+ margin-left: govuk-spacing(9);
}
}
@@ -13,26 +13,11 @@
&-sub-item {
@include bold-24;
list-style: none;
- margin-bottom: $gutter-half;
+ margin-bottom: govuk-spacing(3);
}
&-link {
- @include bold-24;
-
- &-destructive {
- @include bold-24;
- color: $error-colour;
-
- &:visited,
- &:link {
- @include bold-24;
- color: $error-colour;
- }
-
- &:hover {
- color: $mellow-red;
- }
- }
+ @include govuk-font(24, $weight: bold);
}
&-hint {
diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss
index 9189d46d7..3986c65bf 100644
--- a/app/assets/stylesheets/components/checkboxes.scss
+++ b/app/assets/stylesheets/components/checkboxes.scss
@@ -3,7 +3,7 @@
.selection-summary__text {
@include core-19($tabular-numbers: true);
padding: 5px 0 0 0;
- margin-bottom: $gutter / 2;
+ margin-bottom: govuk-spacing(3);
&:focus {
outline: none;
@@ -66,7 +66,7 @@
}
.selection-content {
- margin-bottom: ($gutter / 3) * 2;
+ margin-bottom: govuk-spacing(4);
.checkboxes-nested {
margin-bottom: 0;
diff --git a/app/assets/stylesheets/components/conditional-radios.scss b/app/assets/stylesheets/components/conditional-radios.scss
index e23eccae4..204841740 100644
--- a/app/assets/stylesheets/components/conditional-radios.scss
+++ b/app/assets/stylesheets/components/conditional-radios.scss
@@ -1,5 +1,5 @@
$border-thickness: 4px;
-$top-spacing: $gutter - 5px;
+$top-spacing: govuk-spacing(5);
.multiple-choice {
@@ -17,8 +17,8 @@ $top-spacing: $gutter - 5px;
&-panel {
border-left: $border-thickness solid $border-colour;
- margin: 0 0 (-$gutter-half) ($gutter-half + ($border-thickness / 2));
- padding: $gutter-half 0 0 ($gutter - ($border-thickness / 2));
+ margin: 0 0 (-1 * govuk-spacing(3)) (govuk-spacing(3) + ($border-thickness / 2));
+ padding: govuk-spacing(3) 0 0 (govuk-spacing(6) - ($border-thickness / 2));
position: relative;
top: -$top-spacing;
z-index: 1;
diff --git a/app/assets/stylesheets/components/email-message.scss b/app/assets/stylesheets/components/email-message.scss
index c829a0de3..8147ed3db 100644
--- a/app/assets/stylesheets/components/email-message.scss
+++ b/app/assets/stylesheets/components/email-message.scss
@@ -1,12 +1,12 @@
$white-50-opaque: rgba($white, 0.5);
$button-bottom-border-colour: rgba(0, 0, 0, 0.17);
-$email-message-gutter: $gutter * 2;
+$email-message-gutter: govuk-spacing(9);
// sass-lint:disable no-important
.email-message {
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
border: 1px solid $border-colour;
&-meta {
@@ -49,7 +49,7 @@ $email-message-gutter: $gutter * 2;
width: 100%;
box-sizing: border-box;
- padding: $gutter-half $email-message-gutter 0 $email-message-gutter;
+ padding: govuk-spacing(3) $email-message-gutter 0 $email-message-gutter;
margin: 0 0 0 0;
clear: both;
position: relative;
diff --git a/app/assets/stylesheets/components/file-upload.scss b/app/assets/stylesheets/components/file-upload.scss
index 35adb510e..5e99592f2 100644
--- a/app/assets/stylesheets/components/file-upload.scss
+++ b/app/assets/stylesheets/components/file-upload.scss
@@ -31,7 +31,7 @@
&-filename {
@include bold-19;
display: inline-block;
- padding-left: $gutter-half;
+ padding-left: govuk-spacing(3);
}
&-submit {
@@ -43,7 +43,7 @@
line-height: 35px;
a {
- @include bold-19;
+ font-weight: bold;
}
}
diff --git a/app/assets/stylesheets/components/fullscreen-table.scss b/app/assets/stylesheets/components/fullscreen-table.scss
index 08da3dfad..57c400a31 100644
--- a/app/assets/stylesheets/components/fullscreen-table.scss
+++ b/app/assets/stylesheets/components/fullscreen-table.scss
@@ -6,7 +6,7 @@
z-index: 10;
overflow-y: hidden;
box-sizing: border-box;
- margin: 0 0 $gutter 0;
+ margin: 0 0 govuk-spacing(6) 0;
padding: 0 0 0 0;
overflow: hidden;
border-bottom: 1px solid $border-colour;
diff --git a/app/assets/stylesheets/components/letter.scss b/app/assets/stylesheets/components/letter.scss
index 500fe32ac..84503da16 100644
--- a/app/assets/stylesheets/components/letter.scss
+++ b/app/assets/stylesheets/components/letter.scss
@@ -9,7 +9,7 @@ $iso-paper-ratio: 141.42135624%;
.letter {
padding: $iso-paper-ratio 0 0 0;
- margin: 0 0 $gutter 0;
+ margin: 0 0 govuk-spacing(6) 0;
position: relative;
background: $panel-colour;
@@ -89,3 +89,8 @@ $iso-paper-ratio: 141.42135624%;
}
}
+
+.letter-recipient-summary {
+ line-height: 28px;
+ margin-bottom: 0;
+}
diff --git a/app/assets/stylesheets/components/live-search.scss b/app/assets/stylesheets/components/live-search.scss
index acd868b13..d1fd91b60 100644
--- a/app/assets/stylesheets/components/live-search.scss
+++ b/app/assets/stylesheets/components/live-search.scss
@@ -14,7 +14,7 @@ input[type=search] {
}
.form-group {
- margin-bottom: $gutter * 2 / 3;
+ margin-bottom: govuk-spacing(4);
}
}
diff --git a/app/assets/stylesheets/components/message.scss b/app/assets/stylesheets/components/message.scss
index e6773357b..3d63ff455 100644
--- a/app/assets/stylesheets/components/message.scss
+++ b/app/assets/stylesheets/components/message.scss
@@ -2,7 +2,7 @@
display: inline-block;
vertical-align: top;
width: 20px;
- height: $gutter;
+ height: govuk-spacing(6);
position: relative;
&:before {
@@ -31,18 +31,11 @@
margin: 0;
a {
- margin-bottom: -$gutter;
- padding-bottom: $gutter;
-
- &:hover {
- color: $link-hover-colour;
-
- .message-name-separator {
- &:before {
- border-color: $link-hover-colour;
- }
- }
+ margin-bottom: -1 * govuk-spacing(6);
+ padding-bottom: govuk-spacing(6);
+ &:hover .message-name-separator:before {
+ border-color: $link-hover-colour;
}
&:focus {
@@ -71,7 +64,7 @@
&-type {
color: $secondary-text-colour;
- margin: 0 0 $gutter-two-thirds 0;
+ margin: 0 0 govuk-spacing(4) 0;
pointer-events: none;
}
@@ -79,7 +72,7 @@
#template-list {
- margin-top: $gutter;
+ margin-top: govuk-spacing(6);
&.top-gutter-5px {
margin-top: 5px;
@@ -94,7 +87,7 @@
&-with-checkbox {
position: relative;
- padding-left: $gutter * 2;
+ padding-left: govuk-spacing(9);
.multiple-choice {
position: absolute;
@@ -155,22 +148,22 @@
&-empty {
color: $secondary-text-colour;
- padding: $gutter-half 0 $gutter-one-third 0;
+ padding: govuk-spacing(3) 0 govuk-spacing(2) 0;
}
&-selected-counter {
color: $secondary-text-colour;
- margin: $gutter-half 0;
+ margin: govuk-spacing(3) 0;
@include media(tablet) {
position: absolute;
right: 0;
- top: $gutter - 1px;
+ top: govuk-spacing(6) - 1px;
margin: 0;
}
.content-fixed & {
- right: $gutter-half;
+ right: govuk-spacing(3);
}
}
@@ -179,8 +172,8 @@
.folder-heading {
- .grid-row & {
- margin: $gutter-half 0 20px 0;
+ .govuk-grid-row & {
+ margin: govuk-spacing(3) 0 20px 0;
word-wrap: break-word;
}
@@ -253,7 +246,7 @@
&-manage-link {
display: block;
text-align: right;
- padding: $gutter-two-thirds 0 0 0;
+ padding: govuk-spacing(4) 0 0 0;
position: relative;
top: -6px;
}
diff --git a/app/assets/stylesheets/components/navigation.scss b/app/assets/stylesheets/components/navigation.scss
index 08b6476d4..f2f40ea54 100644
--- a/app/assets/stylesheets/components/navigation.scss
+++ b/app/assets/stylesheets/components/navigation.scss
@@ -1,41 +1,59 @@
.navigation {
- padding: 0 $gutter 0 0;
+ @include govuk-font($size: 19);
+ padding: 0 govuk-spacing(6) 0 0;
$padding-top: 14px;
$padding-bottom: 11px;
+ &-service-name,
+ &-organisation-link {
+ display: inline-block;
+ overflow: hidden;
+ // aligning to the baseline with overflow: hidden adds to the parent's height
+ // aligning to the top doesn't
+ // see: https://stackoverflow.com/questions/23529369/why-does-x-overflowhidden-cause-extra-space-below#answer-51088033
+ vertical-align: top;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ &-service-switch,
+ &-service-back-to,
+ &-organisation-link {
+
+ &:link,
+ &:visited {
+ text-decoration: none;
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ }
+
&-service {
+ @include govuk-font($size: 19);
border-bottom: 1px solid $border-colour;
margin: 0 0 10px;
position: relative;
- font-size: 0;
&-name {
- @include bold-19;
+
padding: $padding-top 0 $padding-bottom 0;
- display: inline-block;
max-width: 50%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
+
}
&-switch {
- @include core-19;
- text-decoration: none;
text-align: right;
position: absolute;
top: 0;
right: 0;
- padding: $padding-top 0 $padding-bottom $gutter-half;
-
- &:hover {
- color: $link-hover-colour;
- text-decoration: underline;
- }
+ padding: $padding-top 0 $padding-bottom govuk-spacing(3);
&:focus {
outline: none;
@@ -43,22 +61,14 @@
border-left: 10px solid $yellow;
border-right: 3px solid $yellow;
right: -3px;
- color: $text-colour;
}
}
&-back-to {
- @include core-19;
- padding: $padding-top $gutter-half $padding-bottom 0;
+ padding: $padding-top govuk-spacing(3) $padding-bottom 0;
display: inline-block;
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- color: $link-hover-colour;
- }
}
@@ -66,16 +76,9 @@
&-organisation-link {
- @include core-19;
- display: inline-block;
max-width: 25%;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- font-weight: normal;
- text-decoration: none;
padding: $padding-top 20px $padding-bottom 0;
- margin-right: 7px;
+ margin-right: 5px;
box-sizing: border-box;
position: relative;
@@ -85,7 +88,7 @@
position: absolute;
top: -1px;
bottom: 1px;
- right: 6px;
+ right: 7px;
width: 7px;
height: 7px;
margin: auto 0;
@@ -97,13 +100,8 @@
border-color: $secondary-text-colour;
}
- &:hover {
- color: $link-hover-colour;
- text-decoration: underline;
- }
-
+ // hack to make the focus style fit in the navigation bar
&:focus {
- color: $text-colour;
outline: none;
box-shadow: 0 1px 0 0 $focus-colour, -3px 0 0 0 $focus-colour, -3px 1px 0 0 $focus-colour;
}
@@ -111,7 +109,6 @@
}
li {
- @include core-19;
margin: 0;
list-style-type: none;
}
@@ -129,14 +126,9 @@
}
&:hover {
- color: $link-hover-colour;
text-decoration: underline;
}
- &:focus {
- color: $text-colour;
- }
-
&.selected {
@include bold-19;
position: relative;
@@ -153,7 +145,7 @@
// https://github.com/alphagov/product-page-example/blob/master/source/stylesheets/modules/_sub-navigation.scss
.sub-navigation {
@include media(tablet) {
- margin-top: $gutter * 1.5;
+ margin-top: govuk-spacing(6) * 1.5;
}
ol,
@@ -168,7 +160,7 @@
border-bottom: 1px $grey-3 solid;
display: block;
- padding: $gutter-one-third 0;
+ padding: govuk-spacing(2) 0;
a:link {
text-decoration: none;
@@ -180,7 +172,7 @@
}
ol ol & {
- padding-left: $gutter;
+ padding-left: govuk-spacing(6);
}
}
diff --git a/app/assets/stylesheets/components/page-footer.scss b/app/assets/stylesheets/components/page-footer.scss
index cdf95ff65..19209a24c 100644
--- a/app/assets/stylesheets/components/page-footer.scss
+++ b/app/assets/stylesheets/components/page-footer.scss
@@ -8,33 +8,16 @@
line-height: 40px;
padding: 1px 0 0 15px;
- a {
-
- &:visited,
- &:link {
- color: $error-colour;
- display: inline-block;
- vertical-align: center;
- }
-
- &:hover,
- &:active {
- color: $mellow-red;
- }
-
- }
-
}
&-delete-link-without-button {
- @include core-19;
padding: 0;
display: inline-block;
}
&-secondary-link {
display: block;
- margin-top: $gutter;
+ margin-top: govuk-spacing(6);
}
&-right-aligned-link {
diff --git a/app/assets/stylesheets/components/pill.scss b/app/assets/stylesheets/components/pill.scss
index d8dab6cdf..72004fa1a 100644
--- a/app/assets/stylesheets/components/pill.scss
+++ b/app/assets/stylesheets/components/pill.scss
@@ -88,7 +88,7 @@
display: block;
text-align: left;
- padding: 10px $gutter-half;
+ padding: 10px govuk-spacing(3);
text-align: center;
&:link,
diff --git a/app/assets/stylesheets/components/preview-pane.scss b/app/assets/stylesheets/components/preview-pane.scss
index 12b8c28c5..9b2c8618f 100644
--- a/app/assets/stylesheets/components/preview-pane.scss
+++ b/app/assets/stylesheets/components/preview-pane.scss
@@ -3,7 +3,7 @@
box-sizing: border-box;
border: solid 1px $border-colour;
min-height: 200px;
- margin-bottom: $gutter
+ margin-bottom: govuk-spacing(6)
}
#logo-img {
diff --git a/app/assets/stylesheets/components/show-more.scss b/app/assets/stylesheets/components/show-more.scss
index cf912a311..3cba1623a 100644
--- a/app/assets/stylesheets/components/show-more.scss
+++ b/app/assets/stylesheets/components/show-more.scss
@@ -4,7 +4,7 @@
@include core-16;
display: block;
padding: 0 0;
- margin: $gutter-half 0 $gutter-half 0;
+ margin: govuk-spacing(3) 0 govuk-spacing(3) 0;
text-align: center;
border-top: 1px solid $border-colour;
diff --git a/app/assets/stylesheets/components/site-footer.scss b/app/assets/stylesheets/components/site-footer.scss
index e4f2e24e1..ad0c1ace8 100644
--- a/app/assets/stylesheets/components/site-footer.scss
+++ b/app/assets/stylesheets/components/site-footer.scss
@@ -3,8 +3,8 @@
margin: 0 auto;
&-wrapper {
- margin-bottom: $gutter;
- padding-bottom: $gutter * 2;
+ margin-bottom: govuk-spacing(6);
+ padding-bottom: govuk-spacing(9);
border-bottom: 1px solid $grey-2;
}
diff --git a/app/assets/stylesheets/components/sms-message.scss b/app/assets/stylesheets/components/sms-message.scss
index 054d6b797..f524532b3 100644
--- a/app/assets/stylesheets/components/sms-message.scss
+++ b/app/assets/stylesheets/components/sms-message.scss
@@ -6,12 +6,12 @@ $tail-angle: 20deg;
width: 100%;
max-width: 464px;
box-sizing: border-box;
- padding: $gutter-half $gutter-half $gutter-half $gutter-half;
+ padding: govuk-spacing(3);
background: $panel-colour;
border: 1px solid $panel-colour;
border-radius: 5px;
white-space: normal;
- margin: 0 0 $gutter 0;
+ margin: 0 0 govuk-spacing(6) 0;
clear: both;
word-wrap: break-word;
@@ -63,7 +63,7 @@ $tail-angle: 20deg;
.sms-message-status {
@include core-16;
color: $secondary-text-colour;
- margin: -20px $gutter-half 20px $gutter-half;
+ margin: -20px govuk-spacing(3) 20px govuk-spacing(3);
}
.sms-message-status-outbound {
diff --git a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss
index c60647f9b..14afd4352 100644
--- a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss
+++ b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss
@@ -1,14 +1,14 @@
// CSS adapted from
// https://github.com/alphagov/govuk_frontend_toolkit/blob/d9489a987086471fe30b4b925a81c12cd198c91d/docs/javascript.md#stick-at-top-when-scrolling
-$sticky-padding: $gutter * 2 / 3;
+$sticky-padding: govuk-spacing(4);
.js-stick-at-top-when-scrolling,
.js-stick-at-bottom-when-scrolling {
overflow: hidden;
- margin-left: -$gutter-half;
- padding: 10px 0 0 $gutter-half;
+ margin-left: -1 * govuk-spacing(3);
+ padding: 10px 0 0 govuk-spacing(3);
position: relative;
.form-group {
@@ -21,8 +21,8 @@ $sticky-padding: $gutter * 2 / 3;
.back-to-top-link {
position: absolute;
- top: $gutter;
- right: $gutter-half;
+ top: govuk-spacing(6);
+ right: govuk-spacing(3);
opacity: 0;
transition: opacity 0.1s ease-in-out;
}
@@ -41,7 +41,7 @@ $sticky-padding: $gutter * 2 / 3;
.js-stick-at-bottom-when-scrolling {
transition: bottom 0.1s ease-out, box-shadow 1s ease-in-out;
- padding: $sticky-padding 0 $sticky-padding $gutter-half;
+ padding: $sticky-padding 0 $sticky-padding govuk-spacing(3);
margin-top: -$sticky-padding;
& + .js-stick-at-bottom-when-scrolling {
@@ -68,7 +68,7 @@ $sticky-padding: $gutter * 2 / 3;
position: fixed;
background: $white;
z-index: 100;
- padding-right: $gutter-half;
+ padding-right: govuk-spacing(3);
margin-top: 0;
.back-to-top-link {
diff --git a/app/assets/stylesheets/components/table.scss b/app/assets/stylesheets/components/table.scss
index 61dba8ed5..86aff3824 100644
--- a/app/assets/stylesheets/components/table.scss
+++ b/app/assets/stylesheets/components/table.scss
@@ -1,11 +1,11 @@
.table {
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
width: 100%;
}
.table-heading {
text-align: left;
- margin: $gutter-half 0 $gutter-half 0;
+ margin: govuk-spacing(3) 0 govuk-spacing(3) 0;
}
.dashboard-table {
@@ -49,6 +49,10 @@
}
}
+ .big-number-label {
+ padding-bottom: 0;
+ }
+
}
.template-statistics-table {
@@ -115,7 +119,7 @@
table {
table-layout: fixed;
- margin-bottom: $gutter / 6;
+ margin-bottom: govuk-spacing(1);
}
th {
@@ -212,15 +216,6 @@
color: $error-colour;
font-weight: bold;
- a {
-
- &:link,
- &:visited {
- color: $error-colour;
- }
-
- }
-
.status-hint {
display: block;
font-weight: normal;
@@ -348,30 +343,20 @@
position: absolute;
- top: -$gutter-half;
+ top: -1 * govuk-spacing(3);
right: 0;
- bottom: -$gutter-half + 4px;
- left: -$gutter-half;
+ bottom: -1 * govuk-spacing(3) + 4px;
+ left: -1 * govuk-spacing(3);
background: transparent;
}
- &:hover {
- color: $link-hover-colour;
- }
-
- &:active,
- &:focus {
-
- color: $black;
-
- &:before {
- border-color: $yellow;
- border-style: solid;
- border-width: 15px 3px 15px 15px;
- right: -3px;
- }
-
+ &:active:before,
+ &:focus:before {
+ border-color: $yellow;
+ border-style: solid;
+ border-width: 15px 3px 15px 15px;
+ right: -3px;
}
}
@@ -396,13 +381,13 @@ td.table-empty-message {
@include core-16;
color: $secondary-text-colour;
- margin-bottom: $gutter * 1.3333;
+ margin-bottom: govuk-spacing(7);
border-bottom: 1px solid $border-colour;
padding: 35px 0 10px 0;
text-align: center;
.table + & {
- margin-top: -$gutter;
+ margin-top: -1 * govuk-spacing(6);
}
}
@@ -415,7 +400,7 @@ a.table-show-more-link {
@include core-16;
color: $secondary-text-colour;
margin-top: 10px;
- margin-bottom: $gutter * 1.3333;
+ margin-bottom: govuk-spacing(7);
border-top: 1px solid $border-colour;
border-bottom: 1px solid $border-colour;
padding: 0.75em 0 0.5625em 0;
diff --git a/app/assets/stylesheets/components/task-list.scss b/app/assets/stylesheets/components/task-list.scss
index ae751d7ea..5fe309920 100644
--- a/app/assets/stylesheets/components/task-list.scss
+++ b/app/assets/stylesheets/components/task-list.scss
@@ -6,7 +6,7 @@ $indicator-colour: $black;
padding: 3px 8px 1px 8px;
position: absolute;
right: 0;
- top: $gutter - 2px;
+ top: govuk-spacing(6) - 2px;
margin-top: -15px;
border: 2px solid $indicator-colour;
pointer-events: none;
@@ -18,7 +18,7 @@ $indicator-colour: $black;
.task-list {
border-top: 1px solid $border-colour;
- margin: $gutter 0;
+ margin: govuk-spacing(6) 0;
&-item {
@@ -27,23 +27,18 @@ $indicator-colour: $black;
a {
border-bottom: 1px solid $border-colour;
display: block;
- padding: $gutter-half 0;
+ padding: govuk-spacing(3) 0;
padding-right: 20%;
position: relative;
- &:hover {
- color: $link-hover-colour;
- }
-
&:focus {
outline: none;
- color: $black;
box-shadow: -3px 0 0 0 $focus-colour, 3px 0 0 0 $focus-colour;
border-color: transparent;
top: -1px;
margin-bottom: -2px;
- padding-top: $gutter-half + 1px;
- padding-bottom: $gutter-half + 1px;
+ padding-top: govuk-spacing(3) + 1px;
+ padding-bottom: govuk-spacing(3) + 1px;
}
}
diff --git a/app/assets/stylesheets/components/textbox.scss b/app/assets/stylesheets/components/textbox.scss
index 4e6c4e484..f3a170eb4 100644
--- a/app/assets/stylesheets/components/textbox.scss
+++ b/app/assets/stylesheets/components/textbox.scss
@@ -36,7 +36,7 @@
overflow-wrap: break-word;
word-wrap: break-word;
border: 2px solid transparent;
- padding-bottom: $gutter-half;
+ padding-bottom: govuk-spacing(3);
z-index: 10;
.placeholder,
diff --git a/app/assets/stylesheets/components/tick-cross.scss b/app/assets/stylesheets/components/tick-cross.scss
index 4136dab7e..9d9ce2489 100644
--- a/app/assets/stylesheets/components/tick-cross.scss
+++ b/app/assets/stylesheets/components/tick-cross.scss
@@ -23,12 +23,10 @@
&-list {
- @extend %grid-row;
position: relative;
&-permissions {
- @include grid-column(3/4);
margin-top: 5px;
li {
@@ -53,30 +51,20 @@
position: absolute;
- top: -$gutter-half - 1;
+ top: -1 * govuk-spacing(3) - 1;
right: 0;
- bottom: -$gutter-half;
- left: -$gutter-half;
+ bottom: -1 * govuk-spacing(3);
+ left: -1 * govuk-spacing(3);
background: transparent;
}
- &:hover {
- color: $link-hover-colour;
- }
-
- &:active,
- &:focus {
-
- color: $black;
-
- &:before {
- border-color: $yellow;
- border-style: solid;
- border-width: 15px 3px 15px 15px;
- right: -3px;
- }
-
+ &:active:before,
+ &:focus:before {
+ border-color: $yellow;
+ border-style: solid;
+ border-width: 15px 3px 15px 15px;
+ right: -3px;
}
}
diff --git a/app/assets/stylesheets/components/vendor/breadcrumbs.scss b/app/assets/stylesheets/components/vendor/breadcrumbs.scss
index 8f1cb149a..53a233b8a 100644
--- a/app/assets/stylesheets/components/vendor/breadcrumbs.scss
+++ b/app/assets/stylesheets/components/vendor/breadcrumbs.scss
@@ -29,14 +29,14 @@
*/
.breadcrumbs {
- @extend %site-width-container;
+ @include govuk-width-container;
@include core-16($line-height: (25 / 16), $line-height-640: 1.75);
- padding: $gutter-one-third 0;
+ padding: govuk-spacing(2) 0;
list-style: none;
@include media(tablet) {
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
}
ol {
@@ -49,7 +49,7 @@
display: inline-block;
margin: 0;
- padding: 0 $gutter-one-quarter 0 (11px + $gutter-one-quarter);
+ padding: 0 (govuk-spacing(6) / 4) 0 (11px + govuk-spacing(6) / 4);
background-image: file-url('separator.png');
background-repeat: no-repeat;
diff --git a/app/assets/stylesheets/components/vendor/previous-next-navigation.scss b/app/assets/stylesheets/components/vendor/previous-next-navigation.scss
index 0e060f12c..940542a4e 100644
--- a/app/assets/stylesheets/components/vendor/previous-next-navigation.scss
+++ b/app/assets/stylesheets/components/vendor/previous-next-navigation.scss
@@ -30,10 +30,11 @@ $is-ie: false !default;
.govuk-previous-and-next-navigation {
display: block;
- margin-top: $gutter;
- margin-bottom: $gutter;
- margin-left: -$gutter-half;
- margin-right: -$gutter-half;
+ margin-top: govuk-spacing(6);
+ margin-bottom: govuk-spacing(6);
+ margin-left: -1 * govuk-spacing(3);
+ margin-right: -1 * govuk-spacing(3);
+ overflow: hidden;
ul {
margin: 0;
@@ -51,13 +52,9 @@ $is-ie: false !default;
a {
display: block;
- padding: $gutter-half;
+ padding: govuk-spacing(3);
text-decoration: none;
- &:visited {
- color: $link-colour;
- }
-
&:hover,
&:active {
background-color: $canvas-colour;
diff --git a/app/assets/stylesheets/globals.scss b/app/assets/stylesheets/globals.scss
index 9da8fb427..b24b39ea4 100644
--- a/app/assets/stylesheets/globals.scss
+++ b/app/assets/stylesheets/globals.scss
@@ -5,6 +5,16 @@
width: device-width;
}
+// To be removed when all text uses the GOV.UK Frontend New Transport font styles.
+// At present, some text gets these styles due to being in a GOV.UK Frontend component
+// or a link.
+// This ensures all text will have these styles applied, until it can be moved to use
+// the GOV.UK Frontend font styles.
+body {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
b,
strong {
font-weight: 600;
@@ -41,34 +51,6 @@ input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
-// To be removed when all links follow the GOV.UK Frontend conventions:
-// https://design-system.service.gov.uk/styles/typography/#links
-a {
- /* Give a strong clear visual idea as to what is currently in focus */
- -webkit-tap-highlight-color: rgba(0, 0, 0, 0.3);
-
- &:link {
- color: $link-colour;
- }
-
- &:visited {
- color: $link-visited-colour;
- }
-
- &:hover {
- color: $link-hover-colour;
- }
-
- &:active {
- color: $link-active-colour;
- }
-
- &:focus {
- background-color: $focus-colour;
- outline: 3px solid $focus-colour;
- }
-}
-
// Each selector, and then the whole block when only one remains, to be removed when the
// element comes from the corresponding GOV.UK Frontend component:
// - https://design-system.service.gov.uk/components/text-input/
diff --git a/app/assets/stylesheets/govuk-frontend/extensions.scss b/app/assets/stylesheets/govuk-frontend/extensions.scss
index 0d4b693fa..80cce8a3b 100644
--- a/app/assets/stylesheets/govuk-frontend/extensions.scss
+++ b/app/assets/stylesheets/govuk-frontend/extensions.scss
@@ -8,3 +8,43 @@
}
}
+// Extends govuk-link to create a class of link that causes a destructive action
+// Based on styles of link in:
+// https://github.com/alphagov/govuk-frontend/blob/v2.13.0/src/helpers/_links.scss
+//
+// Note: all destructive actions must have a confirmation step these links navigate to
+@mixin govuk-link-style-destructive-no-visited-state {
+ &:link,
+ &:visited {
+ color: $govuk-error-colour;
+ }
+
+ &:hover {
+ color: govuk-tint($govuk-error-colour, 25%);
+ }
+
+ // When focussed, the text colour needs to be darker to ensure that colour
+ // contrast is still acceptable against the focus colour
+ // Activated links are usually focused so this applies to them as well
+ &:active,
+ &:focus {
+ color: $govuk-focus-text-colour;
+ }
+}
+
+.govuk-link--destructive {
+ @include govuk-link-style-destructive-no-visited-state;
+}
+$govuk-grid-widths: (
+ one-eighth: 12.5%,
+ one-sixth: 16.6666%,
+ one-quarter: 25%,
+ one-third: 33.3333%,
+ one-half: 50%,
+ five-eighths: 62.5%,
+ two-thirds: 66.6666%,
+ three-quarters: 75%,
+ five-sixths: 83.3333%,
+ seven-eighths: 87.5%,
+ full: 100%
+);
diff --git a/app/assets/stylesheets/local/_typography.scss b/app/assets/stylesheets/local/_typography.scss
new file mode 100644
index 000000000..401562515
--- /dev/null
+++ b/app/assets/stylesheets/local/_typography.scss
@@ -0,0 +1,62 @@
+@import 'settings/all';
+@import 'helpers/all';
+
+@mixin destructive-link-style-default {
+ &:link {
+ color: $govuk-error-colour;
+ }
+
+ &:visited {
+ color: $govuk-link-visited-colour;
+ }
+
+ &:hover {
+ color: govuk-tint( $govuk-error-colour, 25% );
+ }
+
+ &:active {
+ color: $govuk-link-active-colour;
+ }
+
+ // When focussed, the text colour needs to be darker to ensure that colour
+ // contrast is still acceptable
+ &:focus {
+ color: $govuk-focus-text-colour;
+ }
+}
+
+@mixin destructive-link-style-no-visited-state {
+ &:link {
+ color: $govuk-error-colour;
+ }
+
+ &:visited {
+ color: $govuk-error-colour;
+ }
+
+ &:hover {
+ color: govuk-tint( $govuk-error-colour, 25% );
+ }
+
+ &:active {
+ color: $govuk-link-active-colour;
+ }
+
+ // When focussed, the text colour needs to be darker to ensure that colour
+ // contrast is still acceptable
+ &:focus {
+ color: $govuk-focus-text-colour;
+ }
+}
+
+// mimics styles for govuk-link:
+// https://github.com/alphagov/govuk-frontend/blob/063cd8e2470b62b824c6e50ca66342ac7a95d2d8/package/core/_links.scss#L7
+.destructive-link {
+ @include govuk-link-common;
+ @include destructive-link-style-default;
+ @include govuk-link-print-friendly;
+}
+
+.destructive-link--no-visited-state {
+ @include destructive-link-style-no-visited-state;
+}
diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss
index cec02421b..2a9ae48ae 100644
--- a/app/assets/stylesheets/main.scss
+++ b/app/assets/stylesheets/main.scss
@@ -27,7 +27,6 @@ $path: '/static/images/';
@import 'elements/forms';
@import 'elements/forms/form-multiple-choice';
@import 'elements/forms/form-validation';
-@import 'elements/layout';
@import 'elements/lists';
@import 'elements/panels';
@import 'elements/tables';
@@ -36,6 +35,7 @@ $path: '/static/images/';
@import './govuk-frontend/all';
// Specific to this application
+@import 'local/typography';
@import 'grids';
@import 'components/cookie-message';
@import 'components/site-footer';
diff --git a/app/assets/stylesheets/views/api.scss b/app/assets/stylesheets/views/api.scss
index 4049c43c5..f2f1b717b 100644
--- a/app/assets/stylesheets/views/api.scss
+++ b/app/assets/stylesheets/views/api.scss
@@ -18,7 +18,7 @@
&__heading {
display: block;
- margin-bottom: $gutter-half;
+ margin-bottom: govuk-spacing(3);
&::before {
top: -1.3em;
diff --git a/app/assets/stylesheets/views/dashboard.scss b/app/assets/stylesheets/views/dashboard.scss
index b300d5be2..1db274e5f 100644
--- a/app/assets/stylesheets/views/dashboard.scss
+++ b/app/assets/stylesheets/views/dashboard.scss
@@ -5,14 +5,14 @@
}
> .heading-medium:first-of-type {
- margin-top: $gutter-half;
+ margin-top: govuk-spacing(3);
}
}
.keyline-block {
border-top: 1px solid $border-colour;
- padding-top: $gutter-half;
+ padding-top: govuk-spacing(3);
}
.spark-bar {
@@ -21,8 +21,8 @@
box-sizing: border-box;
display: block;
width: 100%;
- margin-bottom: $gutter-half;
- height: $gutter-half;
+ margin-bottom: govuk-spacing(3);
+ height: govuk-spacing(3);
color: $text-colour;
text-align: left;
@@ -56,6 +56,18 @@
margin-top: -10px;
}
+ &-filename-large {
+ @include bold-24;
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding-bottom: 30px;
+ padding-top: 10px;
+ margin-bottom: -30px;
+ margin-top: -10px;
+ }
+
&-filename-unlinked {
@include core-19;
}
@@ -71,6 +83,17 @@
max-width: 580px;
}
+ &-hint-large {
+ @include core-19;
+ display: block;
+ color: $secondary-text-colour;
+ pointer-events: none;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 580px;
+ }
+
}
.failure-highlight {
@@ -80,5 +103,5 @@
.align-with-message-body {
display: block;
- margin-top: $gutter * 5 / 6;
+ margin-top: govuk-spacing(5);
}
diff --git a/app/assets/stylesheets/views/get_started.scss b/app/assets/stylesheets/views/get_started.scss
index b46ae0115..ed914894f 100644
--- a/app/assets/stylesheets/views/get_started.scss
+++ b/app/assets/stylesheets/views/get_started.scss
@@ -5,8 +5,8 @@
&__item {
counter-increment: get-started-counter;
- padding: 0 0 0 $gutter + 5px;
- margin: ($gutter * 1.67) 0 0 0;
+ padding: 0 0 0 govuk-spacing(6) + 5px;
+ margin: govuk-spacing(8) 0 0 0;
position: relative;
&:before {
@@ -26,7 +26,7 @@
&__heading {
@include bold-24;
display: inline-block;
- margin: 5px 0 $gutter-half 0;
+ margin: 5px 0 govuk-spacing(3) 0;
}
}
diff --git a/app/assets/stylesheets/views/history.scss b/app/assets/stylesheets/views/history.scss
index b625c82e1..a8d16514e 100644
--- a/app/assets/stylesheets/views/history.scss
+++ b/app/assets/stylesheets/views/history.scss
@@ -1,13 +1,13 @@
-$item-top-padding: $gutter-half;
+$item-top-padding: govuk-spacing(3);
.history-list {
@include core-19;
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
&-item {
- padding: $item-top-padding 0 $gutter-half 0;
+ padding: $item-top-padding 0 govuk-spacing(3) 0;
border-top: 1px solid $border-colour;
position: relative;
diff --git a/app/assets/stylesheets/views/notification.scss b/app/assets/stylesheets/views/notification.scss
index e26268f56..e0586069a 100644
--- a/app/assets/stylesheets/views/notification.scss
+++ b/app/assets/stylesheets/views/notification.scss
@@ -2,22 +2,18 @@
@include core-16;
color: $secondary-text-colour;
- margin-top: -$gutter-half;
+ margin-top: -1 * govuk-spacing(3);
&.error {
- color: $error-colour;
+ color: $govuk-error-colour;
font-weight: bold;
- a {
- color: $error-colour;
- }
-
}
&-cancelled {
@include bold-19;
- color: $error-colour;
+ color: $govuk-error-colour;
}
}
diff --git a/app/assets/stylesheets/views/product-page.scss b/app/assets/stylesheets/views/product-page.scss
index 927379ff2..d1de13f70 100644
--- a/app/assets/stylesheets/views/product-page.scss
+++ b/app/assets/stylesheets/views/product-page.scss
@@ -11,14 +11,14 @@ $button-shadow-size: $govuk-border-width-form-element;
// We need the `` section to be above it, like the default order, so when we apply the
// negative margin-top it overlaps the theme bar at the bottom of the header
position: relative;
- margin: -10px 0 $gutter * 1.5 0;
- padding: 0 0 $gutter * 2 0;
+ margin: -10px 0 govuk-spacing(6) * 1.5 0;
+ padding: 0 0 govuk-spacing(9) 0;
background: $product-page-blue;
color: $white;
&-wrapper {
- @extend %site-width-container;
+ @include govuk-width-container;
@include media(desktop) {
background-image: file-url('product/proposition-illustration.png');
@@ -31,12 +31,12 @@ $button-shadow-size: $govuk-border-width-form-element;
h1 {
@include bold-48;
- margin: 20px 0 $gutter 0;
+ margin: 20px 0 govuk-spacing(6) 0;
}
p {
@include core-24;
- margin: $gutter-half 0 $gutter;
+ margin: govuk-spacing(3) 0 govuk-spacing(6);
}
.govuk-link {
@@ -57,22 +57,22 @@ $button-shadow-size: $govuk-border-width-form-element;
&-section {
- @extend %site-width-container;
- margin-bottom: $gutter-half;
+ @include govuk-width-container;
+ margin-bottom: govuk-spacing(3);
h2 {
@include bold-27;
- margin: 0 0 $gutter;
+ margin: 0 0 govuk-spacing(6);
}
.with-keyline {
border-top: 1px solid $border-colour;
- padding: $gutter * 1.5 0 0 0;
+ padding: govuk-spacing(6) * 1.5 0 0 0;
}
img {
width: 100%;
- margin: 0 0 $gutter * 1.5 0;
+ margin: 0 0 govuk-spacing(6) * 1.5 0;
}
}
diff --git a/app/assets/stylesheets/views/template.scss b/app/assets/stylesheets/views/template.scss
index df7539f5b..201abefac 100644
--- a/app/assets/stylesheets/views/template.scss
+++ b/app/assets/stylesheets/views/template.scss
@@ -9,7 +9,7 @@
position: absolute;
background: $link-colour;
color: $white;
- padding: 10px $gutter-half;
+ padding: 10px govuk-spacing(3);
z-index: 10000;
&:link, &:visited {
diff --git a/app/assets/stylesheets/views/users.scss b/app/assets/stylesheets/views/users.scss
index 3610e7567..259d14bcc 100644
--- a/app/assets/stylesheets/views/users.scss
+++ b/app/assets/stylesheets/views/users.scss
@@ -1,19 +1,19 @@
-$item-top-padding: $gutter-half;
+$item-top-padding: govuk-spacing(3);
.user-list {
@include core-19;
- margin-bottom: $gutter;
+ margin-bottom: govuk-spacing(6);
&-item {
- padding: $item-top-padding 150px $gutter-half 0;
+ padding: $item-top-padding 150px govuk-spacing(3) 0;
border-top: 1px solid $border-colour;
position: relative;
h3 {
- padding-right: $gutter-half;
+ padding-right: govuk-spacing(3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
diff --git a/app/config.py b/app/config.py
index cd694be4b..9d470974e 100644
--- a/app/config.py
+++ b/app/config.py
@@ -24,7 +24,6 @@ class Config(object):
STATSD_ENABLED = False
STATSD_HOST = os.getenv('STATSD_HOST')
STATSD_PORT = 8125
- STATSD_PREFIX = os.getenv('STATSD_PREFIX')
# Logging
DEBUG = False
@@ -69,6 +68,7 @@ class Config(object):
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None
CSV_UPLOAD_BUCKET_NAME = 'local-notifications-csv-upload'
+ CONTACT_LIST_UPLOAD_BUCKET_NAME = 'local-contact-list'
ACTIVITY_STATS_LIMIT_DAYS = 7
TEST_MESSAGE_FILENAME = 'Report'
@@ -99,6 +99,7 @@ class Development(Config):
SESSION_PROTECTION = None
STATSD_ENABLED = False
CSV_UPLOAD_BUCKET_NAME = 'development-notifications-csv-upload'
+ CONTACT_LIST_UPLOAD_BUCKET_NAME = 'development-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-tools'
MOU_BUCKET_NAME = 'notify.tools-mou'
TRANSIENT_UPLOADED_LETTERS = 'development-transient-uploaded-letters'
@@ -122,6 +123,7 @@ class Test(Development):
STATSD_ENABLED = False
WTF_CSRF_ENABLED = False
CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload'
+ CONTACT_LIST_UPLOAD_BUCKET_NAME = 'test-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-test'
MOU_BUCKET_NAME = 'test-mou'
TRANSIENT_UPLOADED_LETTERS = 'test-transient-uploaded-letters'
@@ -141,6 +143,7 @@ class Preview(Config):
HEADER_COLOUR = '#F499BE' # $baby-pink
STATSD_ENABLED = True
CSV_UPLOAD_BUCKET_NAME = 'preview-notifications-csv-upload'
+ CONTACT_LIST_UPLOAD_BUCKET_NAME = 'preview-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-preview'
MOU_BUCKET_NAME = 'notify.works-mou'
TRANSIENT_UPLOADED_LETTERS = 'preview-transient-uploaded-letters'
@@ -159,6 +162,7 @@ class Staging(Config):
HEADER_COLOUR = '#6F72AF' # $mauve
STATSD_ENABLED = True
CSV_UPLOAD_BUCKET_NAME = 'staging-notifications-csv-upload'
+ CONTACT_LIST_UPLOAD_BUCKET_NAME = 'staging-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-staging'
MOU_BUCKET_NAME = 'staging-notify.works-mou'
TRANSIENT_UPLOADED_LETTERS = 'staging-transient-uploaded-letters'
@@ -174,6 +178,7 @@ class Live(Config):
HTTP_PROTOCOL = 'https'
STATSD_ENABLED = True
CSV_UPLOAD_BUCKET_NAME = 'live-notifications-csv-upload'
+ CONTACT_LIST_UPLOAD_BUCKET_NAME = 'production-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-production'
MOU_BUCKET_NAME = 'notifications.service.gov.uk-mou'
TRANSIENT_UPLOADED_LETTERS = 'production-transient-uploaded-letters'
diff --git a/app/event_handlers.py b/app/event_handlers.py
index 166a694b9..049ea3e66 100644
--- a/app/event_handlers.py
+++ b/app/event_handlers.py
@@ -25,6 +25,15 @@ def create_mobile_number_change_event(user_id, updated_by_id, original_mobile_nu
new_mobile_number=new_mobile_number)
+def create_remove_user_from_service_event(user_id, removed_by_id, service_id):
+ _send_event(
+ 'remove_user_from_service',
+ user_id=user_id,
+ removed_by_id=removed_by_id,
+ service_id=service_id
+ )
+
+
def create_archive_user_event(user_id, archived_by_id):
_send_event(
'archive_user',
diff --git a/app/main/forms.py b/app/main/forms.py
index e9a0bb48f..85639dfa7 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -4,6 +4,7 @@ from itertools import chain
import pytz
from flask import request
+from flask_login import current_user
from flask_wtf import FlaskForm as Form
from flask_wtf.file import FileAllowed
from flask_wtf.file import FileField as FileField_wtf
@@ -42,10 +43,12 @@ from app.main.validators import (
LettersNumbersAndFullStopsOnly,
MustContainAlphanumericCharacters,
NoCommasInPlaceHolders,
+ NoEmbeddedImagesInSVG,
OnlySMSCharacters,
ValidEmail,
ValidGovEmail,
)
+from app.models.feedback import PROBLEM_TICKET_TYPE, QUESTION_TICKET_TYPE
from app.models.organisation import Organisation
from app.models.roles_and_permissions import permissions, roles
from app.utils import guess_name_from_email_address
@@ -499,7 +502,7 @@ class InviteUserForm(PermissionsForm):
self.invalid_email_address = invalid_email_address.lower()
def validate_email_address(self, field):
- if field.data.lower() == self.invalid_email_address:
+ if field.data.lower() == self.invalid_email_address and not current_user.platform_admin:
raise ValidationError("You cannot send an invitation to yourself")
@@ -511,7 +514,7 @@ class InviteOrgUserForm(StripWhitespaceForm):
self.invalid_email_address = invalid_email_address.lower()
def validate_email_address(self, field):
- if field.data.lower() == self.invalid_email_address:
+ if field.data.lower() == self.invalid_email_address and not current_user.platform_admin:
raise ValidationError("You cannot send an invitation to yourself")
@@ -883,23 +886,30 @@ class SupportType(StripWhitespaceForm):
support_type = RadioField(
'How can we help you?',
choices=[
- ('report-problem', 'Report a problem'),
- ('ask-question-give-feedback', 'Ask a question or give feedback'),
+ (PROBLEM_TICKET_TYPE, 'Report a problem'),
+ (QUESTION_TICKET_TYPE, 'Ask a question or give feedback'),
],
validators=[DataRequired()]
)
-class Feedback(StripWhitespaceForm):
- name = StringField('Name')
- email_address = email_address(label='Email address', gov_user=False, required=False)
+class SupportRedirect(StripWhitespaceForm):
+ who = RadioField(
+ 'What do you need help with?',
+ choices=[
+ ('public-sector', 'I work in the public sector and need to send emails, text messages or letters'),
+ ('public', 'I’m a member of the public with a question for the government'),
+ ],
+ validators=[DataRequired()]
+ )
+
+
+class FeedbackOrProblem(StripWhitespaceForm):
+ name = StringField('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 Problem(Feedback):
- email_address = email_address(label='Email address', gov_user=False)
-
-
class Triage(StripWhitespaceForm):
severe = RadioField(
'Is it an emergency?',
@@ -1133,7 +1143,8 @@ class SVGFileUpload(StripWhitespaceForm):
'Upload an SVG logo',
validators=[
FileAllowed(['svg'], 'SVG Images only!'),
- DataRequired(message="You need to upload a file to submit")
+ DataRequired(message="You need to upload a file to submit"),
+ NoEmbeddedImagesInSVG()
]
)
diff --git a/app/main/validators.py b/app/main/validators.py
index 63f36d5f5..39bf2fb0b 100644
--- a/app/main/validators.py
+++ b/app/main/validators.py
@@ -46,7 +46,8 @@ class ValidGovEmail:
message = (
'Enter a government email address.'
' If you think you should have access'
- ' contact us ').format(url_for('main.support'))
+ ' contact us '
+ ).format(url_for('main.support'))
if not is_gov_user(field.data.lower()):
raise ValidationError(message)
@@ -79,6 +80,18 @@ class NoCommasInPlaceHolders:
raise ValidationError(self.message)
+class NoEmbeddedImagesInSVG:
+
+ def __init__(self, message='This SVG has an embedded raster image in it and will not render well'):
+ self.message = message
+
+ def __call__(self, form, field):
+ is_image_embedded = '', methods=['GET', 'POST'])
+def triage(ticket_type=PROBLEM_TICKET_TYPE):
form = Triage()
if form.validate_on_submit():
return redirect(url_for(
'.feedback',
- ticket_type=PROBLEM_TICKET_TYPE,
+ ticket_type=ticket_type,
severe=form.severe.data
))
return render_template(
'views/support/triage.html',
- form=form
+ form=form,
+ page_title={
+ PROBLEM_TICKET_TYPE: 'Report a problem',
+ GENERAL_TICKET_TYPE: 'Contact GOV.UK Notify support',
+ }.get(ticket_type)
)
-@main.route('/support/', methods=['GET', 'POST'])
+@main.route('/support/', methods=['GET', 'POST'])
def feedback(ticket_type):
- try:
- form = {
- QUESTION_TICKET_TYPE: Feedback,
- PROBLEM_TICKET_TYPE: Problem,
- }[ticket_type]()
- except KeyError:
- abort(404)
+ form = FeedbackOrProblem()
if not form.feedback.data:
form.feedback.data = session.pop('feedback_message', '')
@@ -72,14 +84,14 @@ def feedback(ticket_type):
severe = None
out_of_hours_emergency = all((
- ticket_type == PROBLEM_TICKET_TYPE,
+ 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'))
+ return redirect(url_for('.triage', ticket_type=ticket_type))
if needs_escalation(ticket_type, severe):
return redirect(url_for('.bat_phone'))
@@ -99,10 +111,9 @@ def feedback(ticket_type):
else:
service_string = ''
- feedback_msg = '{}\n{}{}'.format(
+ feedback_msg = '{}\n{}'.format(
form.feedback.data,
service_string,
- '' if user_email else '{} (no email address supplied)'.format(form.name.data)
)
zendesk_client.create_ticket(
@@ -121,13 +132,20 @@ def feedback(ticket_type):
),
))
- if not form.feedback.data:
- form.feedback.data = get_prefilled_message()
-
return render_template(
- 'views/support/{}.html'.format(ticket_type),
+ 'views/support/form.html',
form=form,
- ticket_type=ticket_type,
+ 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 GOV.UK Notify support',
+ PROBLEM_TICKET_TYPE: 'Report a problem',
+ QUESTION_TICKET_TYPE: 'Ask a question or give feedback',
+ }.get(ticket_type),
)
@@ -237,7 +255,7 @@ def has_live_services(user_id):
def needs_triage(ticket_type, severe):
return all((
- ticket_type == PROBLEM_TICKET_TYPE,
+ ticket_type != QUESTION_TICKET_TYPE,
severe is None,
(
not current_user.is_authenticated or has_live_services(current_user.id)
@@ -248,7 +266,7 @@ def needs_triage(ticket_type, severe):
def needs_escalation(ticket_type, severe):
return all((
- ticket_type == PROBLEM_TICKET_TYPE,
+ ticket_type != QUESTION_TICKET_TYPE,
severe,
not current_user.is_authenticated,
not in_business_hours(),
diff --git a/app/main/views/index.py b/app/main/views/index.py
index 13effe361..86c244d6f 100644
--- a/app/main/views/index.py
+++ b/app/main/views/index.py
@@ -15,12 +15,12 @@ from notifications_utils.template import HTMLEmailTemplate, LetterImageTemplate
from app import email_branding_client, letter_branding_client, status_api_client
from app.main import main
from app.main.forms import FieldWithNoneOption, SearchByNameForm
-from app.main.views.feedback import QUESTION_TICKET_TYPE
from app.main.views.sub_navigation_dictionaries import (
features_nav,
pricing_nav,
using_notify_nav,
)
+from app.models.feedback import QUESTION_TICKET_TYPE
from app.utils import get_logo_cdn_domain
diff --git a/app/main/views/invites.py b/app/main/views/invites.py
index c1e03d402..e2b32219f 100644
--- a/app/main/views/invites.py
+++ b/app/main/views/invites.py
@@ -12,16 +12,11 @@ from app.models.user import (
User,
Users,
)
-from app.notify_client import InviteTokenError
@main.route("/invitation/")
def accept_invite(token):
- try:
- invited_user = InvitedUser.from_token(token)
- except InviteTokenError as exception:
- flash(str(exception))
- return redirect(url_for('main.sign_in'))
+ invited_user = InvitedUser.from_token(token)
if not current_user.is_anonymous and current_user.email_address.lower() != invited_user.email_address.lower():
message = Markup("""
@@ -78,6 +73,7 @@ def accept_invite(token):
@main.route("/organisation-invitation/")
def accept_org_invite(token):
invited_org_user = InvitedOrgUser.from_token(token)
+
if not current_user.is_anonymous and current_user.email_address.lower() != invited_org_user.email_address.lower():
message = Markup("""
You’re signed in as {}.
diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py
index 50dfd71ff..375bc3523 100644
--- a/app/main/views/jobs.py
+++ b/app/main/views/jobs.py
@@ -51,19 +51,16 @@ def view_jobs(service_id):
if jobs.next_page:
next_page = generate_next_dict('main.view_jobs', service_id, jobs.current_page)
- scheduled_jobs = ''
- if not current_user.has_permissions('view_activity') and jobs.current_page == 1:
- scheduled_jobs = render_template(
- 'views/dashboard/_upcoming.html',
- hide_heading=True,
- )
-
return render_template(
'views/jobs/jobs.html',
jobs=jobs,
prev_page=prev_page,
next_page=next_page,
- scheduled_jobs=scheduled_jobs,
+ show_scheduled_jobs=(
+ jobs.current_page == 1
+ and not current_user.has_permissions('view_activity')
+ and current_service.scheduled_jobs
+ ),
)
@@ -93,10 +90,7 @@ def view_job(service_id, job_id):
status=request.args.get('status', ''),
),
partials=get_job_partials(job),
- just_sent=bool(
- request.args.get('just_sent') == 'yes'
- and job.template_type == 'letter'
- ),
+ just_sent=request.args.get('just_sent') == 'yes',
just_sent_message=just_sent_message,
)
diff --git a/app/main/views/letter_branding.py b/app/main/views/letter_branding.py
index 76edbc08a..76c694e30 100644
--- a/app/main/views/letter_branding.py
+++ b/app/main/views/letter_branding.py
@@ -8,7 +8,6 @@ from flask import (
url_for,
)
from notifications_python_client.errors import HTTPError
-from requests import get as requests_get
from app import letter_branding_client
from app.main import main
@@ -21,11 +20,9 @@ from app.s3_client.s3_logo_client import (
LETTER_TEMP_TAG,
delete_letter_temp_file,
delete_letter_temp_files_created_by,
- get_letter_filename_with_no_path_or_extension,
letter_filename_for_db,
permanent_letter_logo_name,
persist_logo,
- upload_letter_png_logo,
upload_letter_temp_logo,
)
from app.utils import get_logo_cdn_domain, user_is_platform_admin
@@ -87,15 +84,13 @@ def update_letter_branding(branding_id, logo=None):
return redirect(url_for('main.letter_branding'))
else:
- png_file = get_png_file_from_svg(logo)
-
letter_branding_client.update_letter_branding(
branding_id=branding_id,
filename=db_filename,
name=letter_branding_details_form.name.data,
)
- upload_letter_logos(logo, db_filename, png_file, session['user_id'])
+ upload_letter_svg_logo(logo, db_filename, session['user_id'])
return redirect(url_for('main.letter_branding'))
@@ -149,7 +144,6 @@ def create_letter_branding(logo=None):
if details_form_submitted and letter_branding_details_form.validate_on_submit():
if logo:
db_filename = letter_filename_for_db(logo, session['user_id'])
- png_file = get_png_file_from_svg(logo)
try:
letter_branding_client.create_letter_branding(
@@ -157,7 +151,7 @@ def create_letter_branding(logo=None):
name=letter_branding_details_form.name.data,
)
- upload_letter_logos(logo, db_filename, png_file, session['user_id'])
+ upload_letter_svg_logo(logo, db_filename, session['user_id'])
return redirect(url_for('main.letter_branding'))
@@ -179,29 +173,7 @@ def create_letter_branding(logo=None):
)
-def get_png_file_from_svg(filename):
- filename_for_template_preview = get_letter_filename_with_no_path_or_extension(filename)
-
- template_preview_svg_endpoint = '{}/{}.svg.png'.format(
- current_app.config['TEMPLATE_PREVIEW_API_HOST'],
- filename_for_template_preview
- )
-
- response = requests_get(
- template_preview_svg_endpoint,
- headers={'Authorization': 'Token {}'.format(current_app.config['TEMPLATE_PREVIEW_API_KEY'])}
- )
-
- return response.content
-
-
-def upload_letter_logos(old_filename, new_filename, png_file, user_id):
+def upload_letter_svg_logo(old_filename, new_filename, user_id):
persist_logo(old_filename, permanent_letter_logo_name(new_filename, 'svg'))
- upload_letter_png_logo(
- permanent_letter_logo_name(new_filename, 'png'),
- png_file,
- current_app.config['AWS_REGION'],
- )
-
delete_letter_temp_files_created_by(user_id)
diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py
index 5f977bf7c..8102ba521 100644
--- a/app/main/views/manage_users.py
+++ b/app/main/views/manage_users.py
@@ -14,6 +14,7 @@ from app import current_service, service_api_client
from app.event_handlers import (
create_email_change_event,
create_mobile_number_change_event,
+ create_remove_user_from_service_event,
)
from app.main import main
from app.main.forms import (
@@ -132,6 +133,8 @@ def remove_user_from_service(service_id, user_id):
service_id=service_id))
else:
abort(500, e)
+ else:
+ create_remove_user_from_service_event(user_id=user_id, removed_by_id=current_user.id, service_id=service_id)
return redirect(url_for(
'.manage_users',
diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py
index 5cff773e7..710d8f97d 100644
--- a/app/main/views/notifications.py
+++ b/app/main/views/notifications.py
@@ -78,7 +78,10 @@ def view_notification(service_id, notification_id):
page_count = get_page_count_for_letter(notification['template'], values=personalisation)
if notification.get('postage'):
- notification['template']['postage'] = notification['postage']
+ if notification["status"] == "validation-failed":
+ notification['template']['postage'] = None
+ else:
+ notification['template']['postage'] = notification['postage']
template = get_template(
notification['template'],
current_service,
diff --git a/app/main/views/organisations.py b/app/main/views/organisations.py
index 68ae4995d..7b656bbfa 100644
--- a/app/main/views/organisations.py
+++ b/app/main/views/organisations.py
@@ -1,4 +1,5 @@
from collections import OrderedDict
+from functools import partial
from flask import flash, redirect, render_template, request, session, url_for
from flask_login import current_user
@@ -33,6 +34,10 @@ from app.main.forms import (
SetEmailBranding,
SetLetterBranding,
)
+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.organisation import Organisation, Organisations
from app.models.user import InvitedOrgUser, User
@@ -125,8 +130,24 @@ def add_organisation_from_nhs_local_service(service_id):
@main.route("/organisations/", methods=['GET'])
@user_has_permissions()
def organisation_dashboard(org_id):
+ year, current_financial_year = requested_and_current_financial_year(request)
+ services = current_organisation.services_and_usage(
+ financial_year=year
+ )['services']
return render_template(
'views/organisations/organisation/index.html',
+ services=services,
+ years=get_tuples_of_financial_years(
+ partial(url_for, '.organisation_dashboard', org_id=current_organisation.id),
+ start=current_financial_year - 1,
+ end=current_financial_year + 1,
+ ),
+ selected_year=year,
+ search_form=SearchByNameForm() if len(services) > 7 else None,
+ **{
+ f'total_{key}': sum(service[key] for service in services)
+ for key in ('emails_sent', 'sms_cost', 'letter_cost')
+ }
)
diff --git a/app/main/views/platform_admin.py b/app/main/views/platform_admin.py
index 25b727630..50917c819 100644
--- a/app/main/views/platform_admin.py
+++ b/app/main/views/platform_admin.py
@@ -42,6 +42,14 @@ ZERO_FAILURE_THRESHOLD = 0
@main.route("/platform-admin")
@user_is_platform_admin
+def platform_admin_splash_page():
+ return render_template(
+ 'views/platform-admin/splash-page.html',
+ )
+
+
+@main.route("/platform-admin/summary")
+@user_is_platform_admin
def platform_admin():
form = DateFilterForm(request.args, meta={'csrf': False})
api_args = {}
diff --git a/app/main/views/returned_letters.py b/app/main/views/returned_letters.py
index cdc06e464..bb81f3449 100644
--- a/app/main/views/returned_letters.py
+++ b/app/main/views/returned_letters.py
@@ -2,7 +2,7 @@ from collections import OrderedDict
from flask import render_template
-from app import service_api_client
+from app import current_service, service_api_client
from app.main import main
from app.utils import Spreadsheet, user_has_permissions
@@ -10,10 +10,9 @@ from app.utils import Spreadsheet, user_has_permissions
@main.route("/services//returned-letters")
@user_has_permissions('view_activity')
def returned_letter_summary(service_id):
- summary = service_api_client.get_returned_letter_summary(service_id)
return render_template(
'views/returned-letter-summary.html',
- data=summary,
+ data=current_service.returned_letter_summary,
)
diff --git a/app/main/views/send.py b/app/main/views/send.py
index 8764406c4..7784dd902 100644
--- a/app/main/views/send.py
+++ b/app/main/views/send.py
@@ -42,6 +42,7 @@ from app.main.forms import (
SetSenderForm,
get_placeholder_form_instance,
)
+from app.models.contact_list import ContactList, ContactListsAlphabetical
from app.models.user import Users
from app.s3_client.s3_csv_client import (
s3download,
@@ -151,7 +152,7 @@ def send_messages(service_id, template_id):
try:
upload_id = s3upload(
service_id,
- Spreadsheet.from_file(form.file.data, filename=form.file.data.filename).as_dict,
+ Spreadsheet.from_file_form(form).as_dict,
current_app.config['AWS_REGION']
)
return redirect(url_for(
@@ -491,6 +492,48 @@ def send_test_preview(service_id, template_id, filetype):
return TemplatePreview.from_utils_template(template, filetype, page=request.args.get('page'))
+@main.route(
+ '/services//send/'
+ '/from-contact-list'
+)
+@user_has_permissions('send_messages')
+def choose_from_contact_list(service_id, template_id):
+ db_template = current_service.get_template_with_user_permission_or_403(
+ template_id, current_user
+ )
+ template = get_template(
+ db_template, current_service,
+ )
+ return render_template(
+ 'views/send-contact-list.html',
+ contact_lists=ContactListsAlphabetical(
+ current_service.id,
+ template_type=template.template_type,
+ ),
+ template=template,
+ )
+
+
+@main.route(
+ '/services//send/'
+ '/from-contact-list/'
+)
+@user_has_permissions('send_messages')
+def send_from_contact_list(service_id, template_id, contact_list_id):
+ contact_list = ContactList.from_id(
+ contact_list_id,
+ service_id=current_service.id,
+ )
+ return redirect(url_for(
+ 'main.check_messages',
+ service_id=current_service.id,
+ template_id=template_id,
+ upload_id=contact_list.copy_to_uploads(),
+ original_file_name=contact_list.original_file_name,
+ contact_list_id=contact_list.id,
+ ))
+
+
def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_pdf=False):
try:
@@ -700,7 +743,8 @@ def start_job(service_id, upload_id):
job_api_client.create_job(
upload_id,
service_id,
- scheduled_for=request.form.get('scheduled_for', '')
+ scheduled_for=request.form.get('scheduled_for', ''),
+ contact_list_id=request.form.get('contact_list_id', ''),
)
session.pop('sender_id', None)
@@ -907,8 +951,11 @@ def get_template_error_dict(exception):
error = 'not-allowed-to-send-to'
elif 'Exceeded send limits' in exception.message:
error = 'too-many-messages'
+ # the error from the api is changing for message-too-long, but we need both until the api is deployed.
elif 'Content for template has a character count greater than the limit of' in exception.message:
error = 'message-too-long'
+ elif 'Text messages cannot be longer than' in exception.message:
+ error = 'message-too-long'
else:
raise exception
diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py
index 75de09ad6..614f861d9 100644
--- a/app/main/views/service_settings.py
+++ b/app/main/views/service_settings.py
@@ -67,8 +67,8 @@ from app.utils import (
PLATFORM_ADMIN_SERVICE_PERMISSIONS = OrderedDict([
('inbound_sms', {'title': 'Receive inbound SMS', 'requires': 'sms', 'endpoint': '.service_set_inbound_number'}),
('email_auth', {'title': 'Email authentication'}),
- ('upload_document', {'title': 'Send files by email', 'endpoint': '.service_switch_can_upload_document'}),
('upload_letters', {'title': 'Uploading letters', 'requires': 'letter'}),
+ ('international_letters', {'title': 'Send international letters', 'requires': 'letter'}),
])
@@ -294,26 +294,6 @@ def service_set_permission(service_id, permission):
)
-@main.route("/services//service-settings/can-upload-document", methods=['GET', 'POST'])
-@user_is_platform_admin
-def service_switch_can_upload_document(service_id):
- if current_service.contact_link:
- return redirect(url_for('.service_set_permission', service_id=service_id, permission='upload_document'))
-
- form = ServiceContactDetailsForm()
-
- if form.validate_on_submit():
- contact_type = form.contact_details_type.data
-
- current_service.update(
- contact_link=form.data[contact_type]
- )
-
- return redirect(url_for('.service_set_permission', service_id=service_id, permission='upload_document'))
-
- return render_template('views/service-settings/contact_link.html', form=form)
-
-
@main.route("/services//service-settings/archive", methods=['GET', 'POST'])
@user_has_permissions('manage_service')
def archive_service(service_id):
@@ -359,18 +339,20 @@ def resume_service(service_id):
return service_settings(service_id)
-@main.route("/services//service-settings/contact-link", methods=['GET', 'POST'])
+@main.route("/services//service-settings/send-files-by-email", methods=['GET', 'POST'])
@user_has_permissions('manage_service')
-def service_set_contact_link(service_id):
+def send_files_by_email_contact_details(service_id):
form = ServiceContactDetailsForm()
+ contact_details = None
if request.method == 'GET':
contact_details = current_service.contact_link
- contact_type = check_contact_details_type(contact_details)
- field_to_update = getattr(form, contact_type)
+ if contact_details:
+ contact_type = check_contact_details_type(contact_details)
+ field_to_update = getattr(form, contact_type)
- form.contact_details_type.data = contact_type
- field_to_update.data = contact_details
+ form.contact_details_type.data = contact_type
+ field_to_update.data = contact_details
if form.validate_on_submit():
contact_type = form.contact_details_type.data
@@ -380,7 +362,9 @@ def service_set_contact_link(service_id):
)
return redirect(url_for('.service_settings', service_id=current_service.id))
- return render_template('views/service-settings/contact_link.html', form=form)
+ return render_template(
+ 'views/service-settings/send-files-by-email.html', form=form, contact_details=contact_details
+ )
@main.route("/services//service-settings/set-reply-to-email", methods=['GET'])
diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py
index 49ea321f3..567bb3f5a 100644
--- a/app/main/views/sign_in.py
+++ b/app/main/views/sign_in.py
@@ -50,7 +50,7 @@ def sign_in():
flash(Markup(
(
"The email address or password you entered is incorrect."
- " Forgot your password ?"
+ " Forgotten your password? "
).format(password_reset=url_for('.forgot_password'))
))
diff --git a/app/main/views/sub_navigation_dictionaries.py b/app/main/views/sub_navigation_dictionaries.py
index 2316fd083..b3acbb468 100644
--- a/app/main/views/sub_navigation_dictionaries.py
+++ b/app/main/views/sub_navigation_dictionaries.py
@@ -76,6 +76,10 @@ def using_notify_nav():
"name": "Send files by email",
"link": "main.send_files_by_email",
},
+ {
+ "name": "Upload a letter",
+ "link": "main.upload_a_letter",
+ },
]
},
{
diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py
index 0cd8b8444..00e2a3b53 100644
--- a/app/main/views/two_factor.py
+++ b/app/main/views/two_factor.py
@@ -111,7 +111,5 @@ def redirect_when_logged_in(platform_admin):
next_url = request.args.get('next')
if next_url and _is_safe_redirect_url(next_url):
return redirect(next_url)
- if platform_admin:
- return redirect(url_for('main.platform_admin'))
return redirect(url_for('main.show_accounts_or_dashboard'))
diff --git a/app/main/views/uploads.py b/app/main/views/uploads.py
index 60c28c894..4398152eb 100644
--- a/app/main/views/uploads.py
+++ b/app/main/views/uploads.py
@@ -1,25 +1,35 @@
import base64
+import itertools
import json
import urllib
import uuid
from io import BytesIO
+from zipfile import BadZipFile
from flask import (
abort,
current_app,
+ flash,
redirect,
render_template,
request,
+ send_file,
url_for,
)
+from notifications_utils.columns import Columns
from notifications_utils.pdf import pdf_page_count
+from notifications_utils.recipients import RecipientCSV
+from notifications_utils.sanitise_text import SanitiseASCII
from PyPDF2.utils import PdfReadError
from requests import RequestException
+from xlrd.biffh import XLRDError
+from xlrd.xldate import XLDateError
from app import current_service, notification_api_client, service_api_client
from app.extensions import antivirus_client
from app.main import main
-from app.main.forms import LetterUploadPostageForm, PDFUploadForm
+from app.main.forms import CsvUploadForm, LetterUploadPostageForm, PDFUploadForm
+from app.models.contact_list import ContactList
from app.s3_client.s3_letter_upload_client import (
get_letter_metadata,
get_letter_pdf_and_metadata,
@@ -28,10 +38,13 @@ from app.s3_client.s3_letter_upload_client import (
)
from app.template_previews import TemplatePreview, sanitise_letter
from app.utils import (
+ Spreadsheet,
generate_next_dict,
generate_previous_dict,
+ get_errors_for_csv,
get_letter_validation_error,
get_template,
+ unicode_truncate,
user_has_permissions,
)
@@ -46,18 +59,26 @@ def uploads(service_id):
uploads = current_service.get_page_of_uploads(page=request.args.get('page'))
prev_page = None
- if uploads.next_page:
+ if uploads.prev_page:
prev_page = generate_previous_dict('main.uploads', service_id, uploads.current_page)
next_page = None
- if uploads.prev_page:
+ if uploads.next_page:
next_page = generate_next_dict('main.uploads', service_id, uploads.current_page)
+ if uploads.current_page == 1:
+ listed_uploads = (
+ current_service.contact_lists +
+ current_service.scheduled_jobs +
+ uploads
+ )
+ else:
+ listed_uploads = uploads
+
return render_template(
'views/jobs/jobs.html',
- jobs=uploads,
+ jobs=listed_uploads,
prev_page=prev_page,
next_page=next_page,
- scheduled_jobs='',
)
@@ -234,7 +255,6 @@ def uploaded_letter_preview(service_id, file_id):
@main.route("/services//preview-letter-image/")
@user_has_permissions('send_messages')
def view_letter_upload_as_preview(service_id, file_id):
-
try:
page = int(request.args.get('page'))
except ValueError:
@@ -279,3 +299,156 @@ def send_uploaded_letter(service_id):
service_id=service_id,
notification_id=file_id,
))
+
+
+@main.route("/services//upload-contact-list", methods=['GET', 'POST'])
+@user_has_permissions('send_messages')
+def upload_contact_list(service_id):
+ form = CsvUploadForm()
+
+ if form.validate_on_submit():
+ try:
+ upload_id = ContactList.upload(
+ current_service.id,
+ Spreadsheet.from_file_form(form).as_dict,
+ )
+ return redirect(url_for(
+ '.check_contact_list',
+ service_id=service_id,
+ upload_id=upload_id,
+ original_file_name=form.file.data.filename,
+ ))
+ except (UnicodeDecodeError, BadZipFile, XLRDError):
+ flash('Could not read {}. Try using a different file format.'.format(
+ form.file.data.filename
+ ))
+ except (XLDateError):
+ flash((
+ '{} contains numbers or dates that Notify cannot understand. '
+ 'Try formatting all columns as ‘text’ or export your file as CSV.'
+ ).format(
+ form.file.data.filename
+ ))
+
+ return render_template(
+ 'views/uploads/contact-list/upload.html',
+ form=form,
+ )
+
+
+@main.route(
+ "/services//check-contact-list/",
+ methods=['GET', 'POST'],
+)
+@user_has_permissions('send_messages')
+def check_contact_list(service_id, upload_id):
+
+ form = CsvUploadForm()
+
+ contents = ContactList.download(service_id, upload_id)
+ first_row = contents.splitlines()[0].strip().rstrip(',') if contents else ''
+
+ template_type = {
+ 'emailaddress': 'email',
+ 'phonenumber': 'sms',
+ }.get(Columns.make_key(first_row))
+
+ original_file_name = SanitiseASCII.encode(request.args.get('original_file_name', ''))
+
+ recipients = RecipientCSV(
+ contents,
+ template_type=template_type or 'sms',
+ whitelist=itertools.chain.from_iterable(
+ [user.name, user.mobile_number, user.email_address]
+ for user in current_service.active_users
+ ) if current_service.trial_mode else None,
+ international_sms=current_service.has_permission('international_sms'),
+ max_initial_rows_shown=50,
+ max_errors_shown=50,
+ )
+
+ non_empty_column_headers = list(filter(None, recipients.column_headers))
+
+ if len(non_empty_column_headers) > 1 or not template_type or not recipients:
+ return render_template(
+ 'views/uploads/contact-list/too-many-columns.html',
+ recipients=recipients,
+ original_file_name=original_file_name,
+ template_type=template_type,
+ form=form,
+ )
+
+ if recipients.too_many_rows or not len(recipients):
+ return render_template(
+ 'views/uploads/contact-list/column-errors.html',
+ recipients=recipients,
+ original_file_name=original_file_name,
+ form=form,
+ )
+
+ row_errors = get_errors_for_csv(recipients, template_type)
+ if row_errors:
+ return render_template(
+ 'views/uploads/contact-list/row-errors.html',
+ recipients=recipients,
+ original_file_name=original_file_name,
+ row_errors=row_errors,
+ form=form,
+ )
+
+ if recipients.has_errors:
+ return render_template(
+ 'views/uploads/contact-list/column-errors.html',
+ recipients=recipients,
+ original_file_name=original_file_name,
+ form=form,
+ )
+
+ metadata_kwargs = {
+ 'row_count': len(recipients),
+ 'valid': True,
+ 'original_file_name': unicode_truncate(
+ original_file_name,
+ 1600,
+ ),
+ 'template_type': template_type
+ }
+
+ ContactList.set_metadata(service_id, upload_id, **metadata_kwargs)
+
+ return render_template(
+ 'views/uploads/contact-list/ok.html',
+ recipients=recipients,
+ original_file_name=original_file_name,
+ upload_id=upload_id,
+ )
+
+
+@main.route("/services//save-contact-list/", methods=['POST'])
+@user_has_permissions('send_messages')
+def save_contact_list(service_id, upload_id):
+ ContactList.create(current_service.id, upload_id)
+ return redirect(url_for(
+ '.uploads',
+ service_id=current_service.id,
+ ))
+
+
+@main.route("/services//contact-list/", methods=['GET'])
+@user_has_permissions('send_messages')
+def contact_list(service_id, contact_list_id):
+ return render_template(
+ 'views/uploads/contact-list/contact-list.html',
+ contact_list=ContactList.from_id(contact_list_id, service_id=service_id),
+ )
+
+
+@main.route("/services//contact-list/.csv", methods=['GET'])
+@user_has_permissions('send_messages')
+def download_contact_list(service_id, contact_list_id):
+ contact_list = ContactList.from_id(contact_list_id, service_id=service_id)
+ return send_file(
+ filename_or_fp=BytesIO(contact_list.contents.encode('utf-8')),
+ attachment_filename=contact_list.saved_file_name,
+ as_attachment=True,
+ )
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 4f8850e9d..d63f21bbe 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -75,6 +75,5 @@ class ModelList(ABC, Sequence):
def __add__(self, other):
return list(self) + list(other)
-
-class InviteTokenError(Exception):
- pass
+ def __radd__(self, other):
+ return list(other) + list(self)
diff --git a/app/models/contact_list.py b/app/models/contact_list.py
new file mode 100644
index 000000000..70378cb12
--- /dev/null
+++ b/app/models/contact_list.py
@@ -0,0 +1,150 @@
+from functools import partial
+from os import path
+
+from flask import abort, current_app
+from notifications_utils.formatters import strip_whitespace
+from notifications_utils.recipients import RecipientCSV
+from werkzeug.utils import cached_property
+
+from app.models import JSONModel, ModelList
+from app.notify_client.contact_list_api_client import contact_list_api_client
+from app.s3_client.s3_csv_client import (
+ get_csv_metadata,
+ s3download,
+ s3upload,
+ set_metadata_on_csv_upload,
+)
+
+
+class ContactList(JSONModel):
+
+ ALLOWED_PROPERTIES = {
+ 'id',
+ 'created_at',
+ 'created_by',
+ 'service_id',
+ 'original_file_name',
+ 'row_count',
+ 'template_type',
+ }
+
+ upload_type = 'contact_list'
+
+ @classmethod
+ def from_id(cls, contact_list_id, *, service_id):
+ return cls(contact_list_api_client.get_contact_list(
+ service_id=service_id,
+ contact_list_id=contact_list_id,
+ ))
+
+ @staticmethod
+ def get_bucket_name():
+ return current_app.config['CONTACT_LIST_UPLOAD_BUCKET_NAME']
+
+ @staticmethod
+ def upload(service_id, file_dict):
+ return s3upload(
+ service_id,
+ file_dict,
+ current_app.config['AWS_REGION'],
+ bucket=ContactList.get_bucket_name(),
+ )
+
+ @staticmethod
+ def download(service_id, upload_id):
+ return strip_whitespace(s3download(
+ service_id,
+ upload_id,
+ bucket=ContactList.get_bucket_name(),
+ ))
+
+ @staticmethod
+ def set_metadata(service_id, upload_id, **kwargs):
+ return set_metadata_on_csv_upload(
+ service_id,
+ upload_id,
+ bucket=ContactList.get_bucket_name(),
+ **kwargs,
+ )
+
+ @staticmethod
+ def get_metadata(service_id, upload_id):
+ return get_csv_metadata(
+ service_id,
+ upload_id,
+ bucket=ContactList.get_bucket_name(),
+ )
+
+ def copy_to_uploads(self):
+ metadata = self.get_metadata(self.service_id, self.id)
+ new_upload_id = s3upload(
+ self.service_id,
+ {'data': self.contents},
+ current_app.config['AWS_REGION'],
+ )
+ set_metadata_on_csv_upload(
+ self.service_id,
+ new_upload_id,
+ **metadata,
+ )
+ return new_upload_id
+
+ @classmethod
+ def create(cls, service_id, upload_id):
+
+ metadata = cls.get_metadata(service_id, upload_id)
+
+ if not metadata.get('valid'):
+ abort(403)
+
+ return cls(contact_list_api_client.create_contact_list(
+ service_id=service_id,
+ upload_id=upload_id,
+ original_file_name=metadata['original_file_name'],
+ row_count=int(metadata['row_count']),
+ template_type=metadata['template_type'],
+ ))
+
+ @property
+ def contents(self):
+ return self.download(self.service_id, self.id)
+
+ @cached_property
+ def recipients(self):
+ return RecipientCSV(
+ self.contents,
+ template_type=self.template_type,
+ international_sms=True,
+ max_initial_rows_shown=50,
+ )
+
+ @property
+ def saved_file_name(self):
+ file_name, extention = path.splitext(self.original_file_name)
+ return f'{file_name}.csv'
+
+
+class ContactLists(ModelList):
+
+ client_method = contact_list_api_client.get_contact_lists
+ model = ContactList
+ sort_function = partial(
+ sorted,
+ key=lambda item: item['created_at'],
+ reverse=True,
+ )
+
+ def __init__(self, service_id, template_type=None):
+ super().__init__(service_id)
+ self.items = self.sort_function([
+ item for item in self.items
+ if template_type in {item['template_type'], None}
+ ])
+
+
+class ContactListsAlphabetical(ContactLists):
+
+ sort_function = partial(
+ sorted,
+ key=lambda item: item['original_file_name'].lower(),
+ )
diff --git a/app/models/feedback.py b/app/models/feedback.py
new file mode 100644
index 000000000..345be5e6a
--- /dev/null
+++ b/app/models/feedback.py
@@ -0,0 +1,3 @@
+QUESTION_TICKET_TYPE = 'ask-question-give-feedback'
+PROBLEM_TICKET_TYPE = 'report-problem'
+GENERAL_TICKET_TYPE = 'general'
diff --git a/app/models/job.py b/app/models/job.py
index 5cd92c3a0..02d40159e 100644
--- a/app/models/job.py
+++ b/app/models/job.py
@@ -30,6 +30,8 @@ class Job(JSONModel):
'processing_started',
'notification_count',
'created_by',
+ 'template_type',
+ 'recipient',
}
@classmethod
@@ -56,6 +58,10 @@ class Job(JSONModel):
def upload_type(self):
return self._dict.get('upload_type')
+ @property
+ def pdf_letter(self):
+ return self.upload_type == 'letter'
+
@property
def processing_started(self):
if not self._dict.get('processing_started'):
@@ -132,10 +138,6 @@ class Job(JSONModel):
version=self.template_version,
)['data']
- @property
- def template_type(self):
- return self.template['template_type']
-
@property
def percentage_complete(self):
return self.notifications_requested / self.notification_count * 100
diff --git a/app/models/organisation.py b/app/models/organisation.py
index 327be5670..8d1390250 100644
--- a/app/models/organisation.py
+++ b/app/models/organisation.py
@@ -198,6 +198,9 @@ class Organisation(JSONModel):
self.id
)
+ def services_and_usage(self, financial_year):
+ return organisations_client.get_services_and_usage(self.id, financial_year)
+
class Organisations(ModelList):
client_method = organisations_client.get_organisations
diff --git a/app/models/service.py b/app/models/service.py
index 9e4fad965..041ef5e5a 100644
--- a/app/models/service.py
+++ b/app/models/service.py
@@ -1,7 +1,12 @@
+from datetime import datetime, timedelta
+
+from dateutil.parser import parse
from flask import abort, current_app
+from notifications_utils.timezones import local_timezone
from werkzeug.utils import cached_property
from app.models import JSONModel
+from app.models.contact_list import ContactLists
from app.models.job import (
ImmediateJobs,
PaginatedJobs,
@@ -480,6 +485,12 @@ class Service(JSONModel):
def has_inbound_number(self):
return bool(self.inbound_number)
+ @cached_property
+ def inbound_sms_summary(self):
+ if not self.has_permission('inbound_sms'):
+ return None
+ return service_api_client.get_inbound_sms_summary(self.id)
+
@cached_property
def all_template_folders(self):
return sorted(
@@ -660,3 +671,32 @@ class Service(JSONModel):
):
if test:
yield BASE + '_incomplete' + tag
+
+ @cached_property
+ def returned_letter_summary(self):
+ return service_api_client.get_returned_letter_summary(self.id)
+
+ @property
+ def most_recent_returned_letter_report(self):
+ if not self.returned_letter_summary:
+ return None
+ return parse(
+ self.returned_letter_summary[0]['reported_at'] + " 00:00:00"
+ ).replace(tzinfo=local_timezone)
+
+ @property
+ def count_of_returned_letters_in_last_7_days(self):
+ seven_days_ago = (
+ datetime.now() - timedelta(days=7)
+ ).replace(
+ hour=0, minute=0, second=0
+ )
+ return sum(
+ report['returned_letter_count']
+ for report in self.returned_letter_summary
+ if parse(report['reported_at'] + " 00:00:00") >= seven_days_ago
+ )
+
+ @property
+ def contact_lists(self):
+ return ContactLists(self.id)
diff --git a/app/navigation.py b/app/navigation.py
index 503e47ebe..53f01483c 100644
--- a/app/navigation.py
+++ b/app/navigation.py
@@ -7,7 +7,7 @@ class Navigation:
mapping = {}
exclude = {}
- selected_attribute = "class=selected"
+ selected_class = "selected"
def __init__(self):
self.mapping = {
@@ -32,7 +32,7 @@ class Navigation:
def is_selected(self, navigation_item):
if request.endpoint in self.mapping[navigation_item]:
- return self.selected_attribute
+ return " " + self.selected_class
return ''
@staticmethod
@@ -42,13 +42,12 @@ class Navigation:
class HeaderNavigation(Navigation):
- selected_attribute = "class=active"
-
mapping = {
'support': {
'bat_phone',
'feedback',
'support',
+ 'support_public',
'thanks',
'triage',
},
@@ -103,6 +102,7 @@ class HeaderNavigation(Navigation):
'platform_admin_list_complaints',
'platform_admin_reports',
'platform_admin_returned_letters',
+ 'platform_admin_splash_page',
'suspend_service',
'trial_services',
'update_email_branding',
@@ -151,6 +151,7 @@ class HeaderNavigation(Navigation):
'check_notification',
'no_cookie.check_notification_preview',
'choose_account',
+ 'choose_from_contact_list',
'choose_service',
'choose_template',
'choose_template_to_copy',
@@ -171,6 +172,7 @@ class HeaderNavigation(Navigation):
'delivery_and_failure',
'delivery_status_callback',
'design_content',
+ 'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_organisation_agreement',
@@ -261,6 +263,7 @@ class HeaderNavigation(Navigation):
'send_test',
'no_cookie.send_test_preview',
'send_test_step',
+ 'send_from_contact_list',
'send_uploaded_letter',
'service_add_email_reply_to',
'service_add_letter_contact',
@@ -289,7 +292,7 @@ class HeaderNavigation(Navigation):
'service_preview_letter_branding',
'service_set_auth_type',
'service_set_channel',
- 'service_set_contact_link',
+ 'send_files_by_email_contact_details',
'service_set_email_branding',
'service_set_inbound_number',
'service_set_inbound_sms',
@@ -301,7 +304,6 @@ class HeaderNavigation(Navigation):
'service_set_sms_prefix',
'service_settings',
'service_sms_senders',
- 'service_switch_can_upload_document',
'service_switch_count_as_live',
'service_switch_live',
'service_set_permission',
@@ -320,6 +322,10 @@ class HeaderNavigation(Navigation):
'template_history',
'template_usage',
'trial_mode',
+ 'upload_contact_list',
+ 'check_contact_list',
+ 'save_contact_list',
+ 'contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
@@ -354,10 +360,10 @@ class MainNavigation(Navigation):
'conversation',
'inbox',
'monthly',
+ 'returned_letter_summary',
+ 'returned_letters',
'service_dashboard',
'template_usage',
- 'view_job',
- 'view_jobs',
'view_notification',
'view_notifications',
},
@@ -366,6 +372,7 @@ class MainNavigation(Navigation):
'add_service_template',
'check_messages',
'check_notification',
+ 'choose_from_contact_list',
'choose_template',
'choose_template_to_copy',
'confirm_redact_template',
@@ -388,9 +395,15 @@ class MainNavigation(Navigation):
'view_template_versions',
},
'uploads': {
+ 'upload_contact_list',
+ 'check_contact_list',
+ 'save_contact_list',
+ 'contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
+ 'view_job',
+ 'view_jobs',
},
'team-members': {
'confirm_edit_user_email',
@@ -433,7 +446,7 @@ class MainNavigation(Navigation):
'service_preview_letter_branding',
'service_set_auth_type',
'service_set_channel',
- 'service_set_contact_link',
+ 'send_files_by_email_contact_details',
'service_set_email_branding',
'service_set_inbound_number',
'service_set_inbound_sms',
@@ -496,6 +509,7 @@ class MainNavigation(Navigation):
'delivery_and_failure',
'design_content',
'documentation',
+ 'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_organisation_agreement',
@@ -568,6 +582,7 @@ class MainNavigation(Navigation):
'platform_admin_list_complaints',
'platform_admin_reports',
'platform_admin_returned_letters',
+ 'platform_admin_splash_page',
'pricing',
'privacy',
'public_agreement',
@@ -581,21 +596,19 @@ class MainNavigation(Navigation):
'resend_email_link',
'resend_email_verification',
'resume_service',
- 'returned_letter_summary',
- 'returned_letters',
'returned_letters_report',
'revalidate_email_sent',
'roadmap',
'robots',
'security',
'send_notification',
+ 'send_from_contact_list',
'send_uploaded_letter',
'service_dashboard_updates',
'service_delete_email_reply_to',
'service_delete_letter_contact',
'service_delete_sms_sender',
'service_download_agreement',
- 'service_switch_can_upload_document',
'service_switch_count_as_live',
'service_switch_live',
'service_set_permission',
@@ -607,6 +620,7 @@ class MainNavigation(Navigation):
'start_tour',
'styleguide',
'support',
+ 'support_public',
'suspend_service',
'template_history',
'terms',
@@ -652,6 +666,7 @@ class CaseworkNavigation(Navigation):
mapping = {
'send-one-off': {
+ 'choose_from_contact_list',
'choose_template',
'send_one_off',
'send_one_off_step',
@@ -665,6 +680,10 @@ class CaseworkNavigation(Navigation):
'uploads': {
'view_jobs',
'view_job',
+ 'upload_contact_list',
+ 'check_contact_list',
+ 'save_contact_list',
+ 'contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
@@ -731,6 +750,7 @@ class CaseworkNavigation(Navigation):
'delivery_status_callback',
'design_content',
'documentation',
+ 'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_organisation_agreement',
@@ -812,6 +832,7 @@ class CaseworkNavigation(Navigation):
'platform_admin_reports',
'platform_admin_returned_letters',
'platform_admin',
+ 'platform_admin_splash_page',
'pricing',
'privacy',
'public_agreement',
@@ -839,6 +860,7 @@ class CaseworkNavigation(Navigation):
'send_messages',
'send_notification',
'no_cookie.send_test_preview',
+ 'send_from_contact_list',
'send_uploaded_letter',
'service_add_email_reply_to',
'service_add_letter_contact',
@@ -867,7 +889,7 @@ class CaseworkNavigation(Navigation):
'service_preview_letter_branding',
'service_set_auth_type',
'service_set_channel',
- 'service_set_contact_link',
+ 'send_files_by_email_contact_details',
'service_set_email_branding',
'service_set_inbound_number',
'service_set_inbound_sms',
@@ -878,7 +900,6 @@ class CaseworkNavigation(Navigation):
'service_set_sms_prefix',
'service_settings',
'service_sms_senders',
- 'service_switch_can_upload_document',
'service_switch_count_as_live',
'service_switch_live',
'service_set_permission',
@@ -897,6 +918,7 @@ class CaseworkNavigation(Navigation):
'styleguide',
'submit_request_to_go_live',
'support',
+ 'support_public',
'suspend_service',
'template_history',
'template_usage',
@@ -1008,6 +1030,7 @@ class OrgNavigation(Navigation):
'check_notification',
'no_cookie.check_notification_preview',
'choose_account',
+ 'choose_from_contact_list',
'choose_service',
'choose_template',
'choose_template_to_copy',
@@ -1031,6 +1054,7 @@ class OrgNavigation(Navigation):
'delivery_status_callback',
'design_content',
'documentation',
+ 'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_provider',
@@ -1099,6 +1123,7 @@ class OrgNavigation(Navigation):
'platform_admin_list_complaints',
'platform_admin_reports',
'platform_admin_returned_letters',
+ 'platform_admin_splash_page',
'pricing',
'privacy',
'public_agreement',
@@ -1129,6 +1154,7 @@ class OrgNavigation(Navigation):
'send_test',
'no_cookie.send_test_preview',
'send_test_step',
+ 'send_from_contact_list',
'send_uploaded_letter',
'service_add_email_reply_to',
'service_add_letter_contact',
@@ -1157,7 +1183,7 @@ class OrgNavigation(Navigation):
'service_preview_letter_branding',
'service_set_auth_type',
'service_set_channel',
- 'service_set_contact_link',
+ 'send_files_by_email_contact_details',
'service_set_email_branding',
'service_set_inbound_number',
'service_set_inbound_sms',
@@ -1168,7 +1194,6 @@ class OrgNavigation(Navigation):
'service_set_sms_prefix',
'service_settings',
'service_sms_senders',
- 'service_switch_can_upload_document',
'service_switch_count_as_live',
'service_switch_live',
'service_set_permission',
@@ -1187,6 +1212,7 @@ class OrgNavigation(Navigation):
'styleguide',
'submit_request_to_go_live',
'support',
+ 'support_public',
'suspend_service',
'template_history',
'template_usage',
@@ -1201,6 +1227,10 @@ class OrgNavigation(Navigation):
'two_factor_email_sent',
'update_email_branding',
'update_letter_branding',
+ 'upload_contact_list',
+ 'check_contact_list',
+ 'save_contact_list',
+ 'contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
diff --git a/app/notify_client/contact_list_api_client.py b/app/notify_client/contact_list_api_client.py
new file mode 100644
index 000000000..9315d57d8
--- /dev/null
+++ b/app/notify_client/contact_list_api_client.py
@@ -0,0 +1,34 @@
+from app.notify_client import NotifyAdminAPIClient, _attach_current_user
+
+
+class ContactListApiClient(NotifyAdminAPIClient):
+
+ def create_contact_list(
+ self,
+ *,
+ service_id,
+ upload_id,
+ original_file_name,
+ row_count,
+ template_type,
+ ):
+ data = {
+ "id": upload_id,
+ "original_file_name": original_file_name,
+ "row_count": row_count,
+ "template_type": template_type,
+ }
+
+ data = _attach_current_user(data)
+ job = self.post(url='/service/{}/contact-list'.format(service_id), data=data)
+
+ return job
+
+ def get_contact_lists(self, service_id):
+ return self.get(f'/service/{service_id}/contact-list')
+
+ def get_contact_list(self, *, service_id, contact_list_id):
+ return self.get(f'/service/{service_id}/contact-list/{contact_list_id}')
+
+
+contact_list_api_client = ContactListApiClient()
diff --git a/app/notify_client/job_api_client.py b/app/notify_client/job_api_client.py
index 7fa93d52f..f143da94b 100644
--- a/app/notify_client/job_api_client.py
+++ b/app/notify_client/job_api_client.py
@@ -14,8 +14,9 @@ class JobApiClient(NotifyAdminAPIClient):
'ready to send',
'sent to dvla'
}
-
- NON_SCHEDULED_JOB_STATUSES = JOB_STATUSES - {'scheduled', 'cancelled'}
+ SCHEDULED_JOB_STATUS = 'scheduled'
+ CANCELLED_JOB_STATUS = 'cancelled'
+ NON_SCHEDULED_JOB_STATUSES = JOB_STATUSES - {SCHEDULED_JOB_STATUS, CANCELLED_JOB_STATUS}
def get_job(self, service_id, job_id):
params = {}
@@ -65,20 +66,27 @@ class JobApiClient(NotifyAdminAPIClient):
def get_scheduled_jobs(self, service_id):
return sorted(
- self.get_jobs(service_id, statuses=['scheduled'])['data'],
- key=lambda job: job['scheduled_for']
+ self.get_jobs(
+ service_id,
+ statuses=[self.SCHEDULED_JOB_STATUS]
+ )['data'],
+ key=lambda job: job['scheduled_for'],
+ reverse=True,
)
@cache.set('has_jobs-{service_id}')
def has_jobs(self, service_id):
return bool(self.get_jobs(service_id)['data'])
- def create_job(self, job_id, service_id, scheduled_for=None):
+ def create_job(self, job_id, service_id, scheduled_for=None, contact_list_id=None):
data = {"id": job_id}
if scheduled_for:
data.update({'scheduled_for': scheduled_for})
+ if contact_list_id:
+ data.update({'contact_list_id': contact_list_id})
+
data = _attach_current_user(data)
job = self.post(url='/service/{}/job'.format(service_id), data=data)
diff --git a/app/notify_client/organisations_api_client.py b/app/notify_client/organisations_api_client.py
index a62949350..d1c309081 100644
--- a/app/notify_client/organisations_api_client.py
+++ b/app/notify_client/organisations_api_client.py
@@ -87,5 +87,11 @@ class OrganisationsClient(NotifyAdminAPIClient):
params={"org_id": org_id, "name": name}
)["result"]
+ def get_services_and_usage(self, org_id, year):
+ return self.get(
+ url=f"/organisations/{org_id}/services-with-usage",
+ params={"year": str(year)}
+ )
+
organisations_client = OrganisationsClient()
diff --git a/app/s3_client/s3_csv_client.py b/app/s3_client/s3_csv_client.py
index f9d0b5b9f..8ddfb1a57 100644
--- a/app/s3_client/s3_csv_client.py
+++ b/app/s3_client/s3_csv_client.py
@@ -9,20 +9,20 @@ from app.s3_client.s3_logo_client import get_s3_object
FILE_LOCATION_STRUCTURE = 'service-{}-notify/{}.csv'
-def get_csv_location(service_id, upload_id):
+def get_csv_location(service_id, upload_id, bucket=None):
return (
- current_app.config['CSV_UPLOAD_BUCKET_NAME'],
+ bucket or current_app.config['CSV_UPLOAD_BUCKET_NAME'],
FILE_LOCATION_STRUCTURE.format(service_id, upload_id),
)
-def get_csv_upload(service_id, upload_id):
- return get_s3_object(*get_csv_location(service_id, upload_id))
+def get_csv_upload(service_id, upload_id, bucket=None):
+ return get_s3_object(*get_csv_location(service_id, upload_id, bucket))
-def s3upload(service_id, filedata, region):
+def s3upload(service_id, filedata, region, bucket=None):
upload_id = str(uuid.uuid4())
- bucket_name, file_location = get_csv_location(service_id, upload_id)
+ bucket_name, file_location = get_csv_location(service_id, upload_id, bucket)
utils_s3upload(
filedata=filedata['data'],
region=region,
@@ -32,10 +32,10 @@ def s3upload(service_id, filedata, region):
return upload_id
-def s3download(service_id, upload_id):
+def s3download(service_id, upload_id, bucket=None):
contents = ''
try:
- key = get_csv_upload(service_id, upload_id)
+ key = get_csv_upload(service_id, upload_id, bucket)
contents = key.get()['Body'].read().decode('utf-8')
except botocore.exceptions.ClientError as e:
current_app.logger.error("Unable to download s3 file {}".format(
@@ -44,14 +44,33 @@ def s3download(service_id, upload_id):
return contents
-def set_metadata_on_csv_upload(service_id, upload_id, **kwargs):
+def set_metadata_on_csv_upload(service_id, upload_id, bucket=None, **kwargs):
get_csv_upload(
- service_id, upload_id
+ service_id, upload_id, bucket=bucket
).copy_from(
- CopySource='{}/{}'.format(*get_csv_location(service_id, upload_id)),
+ CopySource='{}/{}'.format(*get_csv_location(service_id, upload_id, bucket=bucket)),
ServerSideEncryption='AES256',
Metadata={
key: str(value) for key, value in kwargs.items()
},
MetadataDirective='REPLACE',
)
+
+
+def set_metadata_on_contact_list(service_id, upload_id, **kwargs):
+ return set_metadata_on_csv_upload(
+ service_id,
+ upload_id,
+ bucket=current_app.config['CONTACT_LIST_UPLOAD_BUCKET_NAME'],
+ **kwargs,
+ )
+
+
+def get_csv_metadata(service_id, upload_id, bucket=None):
+ try:
+ key = get_csv_upload(service_id, upload_id, bucket)
+ return key.get()['Metadata']
+ except botocore.exceptions.ClientError as e:
+ current_app.logger.error("Unable to download s3 file {}".format(
+ FILE_LOCATION_STRUCTURE.format(service_id, upload_id)))
+ raise e
diff --git a/app/s3_client/s3_logo_client.py b/app/s3_client/s3_logo_client.py
index bb8626bae..3e280bc25 100644
--- a/app/s3_client/s3_logo_client.py
+++ b/app/s3_client/s3_logo_client.py
@@ -80,17 +80,6 @@ def upload_letter_temp_logo(filename, filedata, region, user_id):
return upload_filename
-def upload_letter_png_logo(filename, filedata, region):
- bucket_name = current_app.config['LOGO_UPLOAD_BUCKET_NAME']
- utils_s3upload(
- filedata=filedata,
- region=region,
- bucket_name=bucket_name,
- file_location=filename,
- content_type='image/png'
- )
-
-
def permanent_email_logo_name(filename, user_id):
if filename.startswith(TEMP_TAG.format(user_id=user_id)):
return get_temp_truncated_filename(filename=filename, user_id=user_id)
diff --git a/app/templates/admin_template.html b/app/templates/admin_template.html
index 91eec3123..96e3d0cf4 100644
--- a/app/templates/admin_template.html
+++ b/app/templates/admin_template.html
@@ -55,7 +55,7 @@
"active": header_navigation.is_selected('user-profile')
},
{
- "href": url_for('main.platform_admin'),
+ "href": url_for('main.platform_admin_splash_page'),
"text": "Platform admin",
"active": header_navigation.is_selected('platform-admin')
},
diff --git a/app/templates/components/ajax-block.html b/app/templates/components/ajax-block.html
index 4d40a25b3..c0aa403d8 100644
--- a/app/templates/components/ajax-block.html
+++ b/app/templates/components/ajax-block.html
@@ -1,4 +1,4 @@
-{% macro ajax_block(partials, url, key, interval=2, finished=False, form='') %}
+{% macro ajax_block(partials, url, key, interval=5, finished=False, form='') %}
{% if not finished %}
+
{% endif %}
@@ -42,7 +42,7 @@
{% if failures %}
{% if failure_link %}
-
+
{{ "{:,}".format(failures) }}
failed – {{ failure_percentage }}%
diff --git a/app/templates/components/browse-list.html b/app/templates/components/browse-list.html
index d59e9ae37..b75d09218 100644
--- a/app/templates/components/browse-list.html
+++ b/app/templates/components/browse-list.html
@@ -5,7 +5,7 @@
{% for item in items %}
{% if item.title and item.link %}
- {{ item.title }}
+ {{ item.title }}
{% if item.hint %}
{{ item.hint }}
diff --git a/app/templates/components/file-upload.html b/app/templates/components/file-upload.html
index 647629248..1908f1e48 100644
--- a/app/templates/components/file-upload.html
+++ b/app/templates/components/file-upload.html
@@ -32,7 +32,7 @@
{% if alternate_link and alternate_link_text %}
- or {{ alternate_link_text }}
+ or {{ alternate_link_text }}
{% endif %}
diff --git a/app/templates/components/folder-path.html b/app/templates/components/folder-path.html
index 5400e91c2..0d1900745 100644
--- a/app/templates/components/folder-path.html
+++ b/app/templates/components/folder-path.html
@@ -17,12 +17,12 @@
{% else %}
{% if folder.id %}
{% if current_user.has_template_folder_permission(folder) %}
-
{{ folder.name }}
+
{{ folder.name }}
{% else %}
{{ folder.name }}
{% endif %}
{% else %}
-
Templates
+
Templates
{% endif %}
{% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% endif %}
@@ -40,7 +40,7 @@
{% if folder_path %}
{% if folder_path|length == 1 %}
- Services
+ Services
{{ folder_path_separator() }}
{% endif %}
{% for folder in folder_path %}
@@ -51,15 +51,15 @@
{% else %}
{% if folder.id %}
{% if current_user.has_template_folder_permission(folder) %}
- {{ folder.name }}
+ {{ folder.name }}
{% else %}
{{ folder.name }}
{% endif %}
{% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% elif folder.parent_id == None %}
- {{ from_service.name }} {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
+ {{ from_service.name }} {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% else %}
- {{ from_service.name }} {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
+ {{ from_service.name }} {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% endif %}
{% endif %}
{% endfor %}
diff --git a/app/templates/components/page-footer.html b/app/templates/components/page-footer.html
index fb9f478be..4d06641d5 100644
--- a/app/templates/components/page-footer.html
+++ b/app/templates/components/page-footer.html
@@ -26,11 +26,11 @@
{% endif %}
{% if delete_link %}
{% endif %}
{% if secondary_link and secondary_link_text %}
-
+
{% endif %}
{% endmacro %}
diff --git a/app/templates/components/pill.html b/app/templates/components/pill.html
index bcbb5408b..d6a416522 100644
--- a/app/templates/components/pill.html
+++ b/app/templates/components/pill.html
@@ -13,7 +13,7 @@