Merge branch 'master' into itsdangerous-upgrade

This commit is contained in:
Chris Hill-Scott
2020-03-25 16:40:39 +00:00
committed by GitHub
362 changed files with 5324 additions and 2407 deletions
-1
View File
@@ -62,7 +62,6 @@ target/
.vscode
# Frontend dependencies and compiled assets
app/assets/stylesheets/govuk_template/.sass-cache/
.sass-cache/
cache/
node_modules
+35 -52
View File
@@ -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}
-4
View File
@@ -1,7 +1,3 @@
[![Requirements Status](https://requires.io/github/alphagov/notifications-admin/requirements.svg?branch=master)](https://requires.io/github/alphagov/notifications-admin/requirements/?branch=master)
[![Coverage Status](https://coveralls.io/repos/alphagov/notifications-admin/badge.svg?branch=master&service=github)](https://coveralls.io/github/alphagov/notifications-admin?branch=master)
# notifications-admin
GOV.UK Notify admin application.
+71 -35
View File
@@ -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,
+1 -1
View File
@@ -75,7 +75,7 @@
Try again later.
</p>
<p class="govuk-body">
You can check our <a href="https://status.notifications.service.gov.uk/">system status</a> page to see if there are any known issues.<br/>To report a problem, email <a href="mailto:notify-support@digital.cabinet-office.gov.uk">notify-support@digital.cabinet-office.gov.uk</a>
You can check our <a class="govuk-link govuk-link--no-visited-state" href="https://status.notifications.service.gov.uk/">system status</a> page to see if there are any known issues.<br/>To report a problem, email <a href="mailto:notify-support@digital.cabinet-office.gov.uk">notify-support@digital.cabinet-office.gov.uk</a>
</div>
</div>
</main>
@@ -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');
};
+3 -1
View File
@@ -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(
+2 -2
View File
@@ -10,7 +10,7 @@
branding_style = branding_style.val();
const $paneWrapper = $('<div class="column-full"></div>');
const $paneWrapper = $('<div class="govuk-grid-column-full"></div>');
const $form = $('form');
const previewType = $form.data('previewType');
const $previewPane = $(`<iframe src="/_${previewType}?${buildQueryString(['branding_style', branding_style])}" class="branding-preview"></iframe>`);
@@ -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');
+1 -1
View File
@@ -114,7 +114,7 @@
this.makeButton = (text, opts) => {
let $btn = $('<a href=""></a>')
.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')
+25 -48
View File
@@ -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);
}
+5 -15
View File
@@ -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);
}
+20 -33
View File
@@ -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;
}
}
@@ -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;
@@ -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 {
@@ -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;
@@ -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;
@@ -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;
@@ -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;
}
}
@@ -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;
@@ -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;
}
@@ -14,7 +14,7 @@ input[type=search] {
}
.form-group {
margin-bottom: $gutter * 2 / 3;
margin-bottom: govuk-spacing(4);
}
}
+15 -22
View File
@@ -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;
}
@@ -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);
}
}
@@ -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 {
+1 -1
View File
@@ -88,7 +88,7 @@
display: block;
text-align: left;
padding: 10px $gutter-half;
padding: 10px govuk-spacing(3);
text-align: center;
&:link,
@@ -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 {
@@ -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;
@@ -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;
}
@@ -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 {
@@ -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 {
+19 -34
View File
@@ -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;
@@ -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;
}
}
@@ -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,
@@ -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;
}
}
+4 -4
View File
@@ -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;
@@ -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;
+10 -28
View File
@@ -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/
@@ -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%
);
@@ -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;
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -18,7 +18,7 @@
&__heading {
display: block;
margin-bottom: $gutter-half;
margin-bottom: govuk-spacing(3);
&::before {
top: -1.3em;
+28 -5
View File
@@ -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);
}
@@ -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;
}
}
+3 -3
View File
@@ -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;
@@ -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;
}
}
+10 -10
View File
@@ -11,14 +11,14 @@ $button-shadow-size: $govuk-border-width-form-element;
// We need the `<main>` 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;
}
}
+1 -1
View File
@@ -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 {
+4 -4
View File
@@ -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;
+6 -1
View File
@@ -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'
+9
View File
@@ -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',
+23 -12
View File
@@ -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', 'Im 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()
]
)
+14 -1
View File
@@ -46,7 +46,8 @@ class ValidGovEmail:
message = (
'Enter a government email address.'
' If you think you should have access'
' <a href="{}">contact us</a>').format(url_for('main.support'))
' <a class="govuk-link govuk-link--no-visited-state" href="{}">contact us</a>'
).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 = '<image' in field.data.stream.read().decode("utf-8")
field.data.stream.seek(0)
if is_image_embedded:
raise ValidationError(self.message)
class OnlySMSCharacters:
def __call__(self, form, field):
non_sms_characters = sorted(list(SanitiseSMS.get_non_compatible_characters(field.data)))
-8
View File
@@ -296,10 +296,6 @@ def get_dashboard_partials(service_id):
),
'inbox': render_template(
'views/dashboard/_inbox.html',
inbound_sms_summary=(
service_api_client.get_inbound_sms_summary(service_id)
if current_service.has_permission('inbound_sms') else None
),
),
'totals': render_template(
'views/dashboard/_totals.html',
@@ -313,10 +309,6 @@ def get_dashboard_partials(service_id):
[row['count'] for row in template_statistics] or [0]
),
),
'jobs': render_template(
'views/dashboard/_jobs.html',
jobs=current_service.immediate_jobs,
),
'usage': render_template(
'views/dashboard/_usage.html',
**calculate_usage(yearly_usage, free_sms_allowance),
+65 -47
View File
@@ -1,67 +1,79 @@
from datetime import datetime
import pytz
from flask import abort, redirect, render_template, request, session, url_for
from flask import redirect, render_template, request, session, url_for
from flask_login import current_user
from app import convert_to_boolean, current_service, service_api_client
from app.extensions import zendesk_client
from app.main import main
from app.main.forms import Feedback, Problem, SupportType, Triage
QUESTION_TICKET_TYPE = 'ask-question-give-feedback'
PROBLEM_TICKET_TYPE = "report-problem"
def get_prefilled_message():
return {
'agreement': (
'Please can you tell me if theres an agreement in place '
'between GOV.UK Notify and my organisation?'
),
'letter-branding': (
'I would like my own logo on my letter templates.'
),
}.get(
request.args.get('body'), ''
)
from app.main.forms import (
FeedbackOrProblem,
SupportRedirect,
SupportType,
Triage,
)
from app.models.feedback import (
GENERAL_TICKET_TYPE,
PROBLEM_TICKET_TYPE,
QUESTION_TICKET_TYPE,
)
@main.route('/support', methods=['GET', 'POST'])
def support():
form = SupportType()
if form.validate_on_submit():
return redirect(url_for(
'.feedback',
ticket_type=form.support_type.data,
))
if current_user.is_authenticated:
form = SupportType()
if form.validate_on_submit():
return redirect(url_for(
'.feedback',
ticket_type=form.support_type.data,
))
else:
form = SupportRedirect()
if form.validate_on_submit():
if form.who.data == 'public':
return redirect(url_for(
'.support_public'
))
else:
return redirect(url_for(
'.feedback',
ticket_type=GENERAL_TICKET_TYPE,
))
return render_template('views/support/index.html', form=form)
@main.route('/support/public')
def support_public():
return render_template('views/support/public.html')
@main.route('/support/triage', methods=['GET', 'POST'])
def triage():
@main.route('/support/triage/<ticket_type:ticket_type>', 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/<ticket_type>', methods=['GET', 'POST'])
@main.route('/support/<ticket_type:ticket_type>', 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(),
+1 -1
View File
@@ -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
+2 -6
View File
@@ -12,16 +12,11 @@ from app.models.user import (
User,
Users,
)
from app.notify_client import InviteTokenError
@main.route("/invitation/<token>")
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/<token>")
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("""
Youre signed in as {}.
+6 -12
View File
@@ -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,
)
+3 -31
View File
@@ -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)
+3
View File
@@ -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',
+4 -1
View File
@@ -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,
+21
View File
@@ -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/<uuid:org_id>", 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')
}
)
+8
View File
@@ -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 = {}
+2 -3
View File
@@ -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/<uuid:service_id>/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,
)
+49 -2
View File
@@ -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/<uuid:service_id>/send/<uuid:template_id>'
'/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/<uuid:service_id>/send/<uuid:template_id>'
'/from-contact-list/<uuid:contact_list_id>'
)
@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
+12 -28
View File
@@ -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/<uuid:service_id>/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/<uuid:service_id>/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/<uuid:service_id>/service-settings/contact-link", methods=['GET', 'POST'])
@main.route("/services/<uuid:service_id>/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/<uuid:service_id>/service-settings/set-reply-to-email", methods=['GET'])
+1 -1
View File
@@ -50,7 +50,7 @@ def sign_in():
flash(Markup(
(
"The email address or password you entered is incorrect."
" <a href={password_reset}>Forgot your password</a>?"
" <a href={password_reset}>Forgotten your password?</a>"
).format(password_reset=url_for('.forgot_password'))
))
@@ -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",
},
]
},
{
-2
View File
@@ -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'))
+179 -6
View File
@@ -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/<uuid:service_id>/preview-letter-image/<uuid:file_id>")
@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/<uuid:service_id>/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/<uuid:service_id>/check-contact-list/<uuid:upload_id>",
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/<uuid:service_id>/save-contact-list/<uuid:upload_id>", 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/<uuid:service_id>/contact-list/<uuid:contact_list_id>", 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/<uuid:service_id>/contact-list/<uuid:contact_list_id>.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,
)
+2 -3
View File
@@ -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)
+150
View File
@@ -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(),
)
+3
View File
@@ -0,0 +1,3 @@
QUESTION_TICKET_TYPE = 'ask-question-give-feedback'
PROBLEM_TICKET_TYPE = 'report-problem'
GENERAL_TICKET_TYPE = 'general'
+6 -4
View File
@@ -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
+3
View File
@@ -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
+40
View File
@@ -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)
+46 -16
View File
@@ -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',
@@ -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()
+13 -5
View File
@@ -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)
@@ -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()
+30 -11
View File
@@ -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
-11
View File
@@ -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)
+1 -1
View File
@@ -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')
},
+1 -1
View File
@@ -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 %}
<div
data-module="update-content"
+2 -2
View File
@@ -1,6 +1,6 @@
{% macro big_number(number, label, link=None, currency='', smaller=False, smallest=False) %}
{% if link %}
<a class="big-number-link" href="{{ link }}">
<a class="govuk-link govuk-link--no-visited-state big-number-link" href="{{ link }}">
{% endif %}
<div class="big-number{% if smaller %}-smaller{% endif %}{% if smallest %}-smallest{% endif %}">
<div class="big-number-number">
@@ -42,7 +42,7 @@
<div class="big-number-status{% if danger_zone %}-failing{% endif %}">
{% if failures %}
{% if failure_link %}
<a href="{{ failure_link }}">
<a class="govuk-link govuk-link--no-visited-state" href="{{ failure_link }}">
{{ "{:,}".format(failures) }}
failed {{ failure_percentage }}%
</a>
+1 -1
View File
@@ -5,7 +5,7 @@
{% for item in items %}
{% if item.title and item.link %}
<li class="browse-list-item">
<a href="{{ item.link }}" class="browse-list-link{% if item.destructive %}-destructive{% endif %}">{{ item.title }}</a>
<a href="{{ item.link }}" class="govuk-link govuk-link--{% if item.destructive %}--destructive{% else %}--no-visited-state{% endif %}">{{ item.title }}</a>
{% if item.hint %}
<div class="browse-list-hint">
{{ item.hint }}
+1 -1
View File
@@ -32,7 +32,7 @@
</label>
{% if alternate_link and alternate_link_text %}
<span class="file-upload-alternate-link">
or <a href="{{ alternate_link }}">{{ alternate_link_text }}</a>
or <a class="govuk-link govuk-link--no-visited-state" href="{{ alternate_link }}">{{ alternate_link_text }}</a>
</span>
{% endif %}
<label class="file-upload-filename" for="{{ field.name }}"></label>
+6 -6
View File
@@ -17,12 +17,12 @@
{% else %}
{% if folder.id %}
{% if current_user.has_template_folder_permission(folder) %}
<a href="{{ url_for('.choose_template', service_id=service_id, template_type=template_type, template_folder_id=folder.id) }}" class="folder-heading-folder {% if loop.index < (loop.length - 1) %}folder-heading-folder-truncated{% endif %}" title="{{ folder.name }}">{{ folder.name }}</a>
<a href="{{ url_for('.choose_template', service_id=service_id, template_type=template_type, template_folder_id=folder.id) }}" class="govuk-link govuk-link--no-visited-state folder-heading-folder {% if loop.index < (loop.length - 1) %}folder-heading-folder-truncated{% endif %}" title="{{ folder.name }}">{{ folder.name }}</a>
{% else %}
<span class="folder-heading-folder">{{ folder.name }}</span>
{% endif %}
{% else %}
<a href="{{ url_for('.choose_template', service_id=service_id, template_type=template_type) }}" title="Templates" class="{% if loop.length > 2 %}folder-heading-folder-root-truncated{% endif %}">Templates</a>
<a href="{{ url_for('.choose_template', service_id=service_id, template_type=template_type) }}" title="Templates" class="govuk-link govuk-link--no-visited-state {% if loop.length > 2 %}folder-heading-folder-root-truncated{% endif %}">Templates</a>
{% endif %}
{% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% endif %}
@@ -40,7 +40,7 @@
{% if folder_path %}
<h2 class="heading-medium folder-heading">
{% if folder_path|length == 1 %}
<a href="{{ url_for('.choose_template_to_copy', service_id=current_service_id) }}">Services</a>
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.choose_template_to_copy', service_id=current_service_id) }}">Services</a>
{{ 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) %}
<a href="{{ url_for('.choose_template_to_copy', service_id=current_service_id, from_service=from_service.id, from_folder=folder.id) }}" class="folder-heading-folder">{{ folder.name }}</a>
<a href="{{ url_for('.choose_template_to_copy', service_id=current_service_id, from_service=from_service.id, from_folder=folder.id) }}" class="govuk-link govuk-link--no-visited-state folder-heading-folder">{{ folder.name }}</a>
{% else %}
<span class="folder-heading-folder">{{ folder.name }}</span>
{% endif %}
{% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% elif folder.parent_id == None %}
<a href="{{ url_for('.choose_template_to_copy', service_id=current_service_id, from_service=from_service.id, from_folder=folder.id) }}" class="folder-heading-folder">{{ from_service.name }}</a> {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
<a href="{{ url_for('.choose_template_to_copy', service_id=current_service_id, from_service=from_service.id, from_folder=folder.id) }}" class="govuk-link govuk-link--no-visited-state folder-heading-folder">{{ from_service.name }}</a> {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% else %}
<a href="{{ url_for('.choose_template_to_copy', service_id=current_service_id, from_service=from_service.id) }}">{{ from_service.name }}</a> {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.choose_template_to_copy', service_id=current_service_id, from_service=from_service.id) }}">{{ from_service.name }}</a> {% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% endif %}
{% endif %}
{% endfor %}
+2 -2
View File
@@ -26,11 +26,11 @@
{% endif %}
{% if delete_link %}
<span class="page-footer-delete-link {% if not button_text %}page-footer-delete-link-without-button{% endif %}">
<a href="{{ delete_link }}">{{ delete_link_text }}</a>
<a class="govuk-link govuk-link--destructive" href="{{ delete_link }}">{{ delete_link_text }}</a>
</span>
{% endif %}
{% if secondary_link and secondary_link_text %}
<a class="page-footer-secondary-link" href="{{ secondary_link }}">{{ secondary_link_text }}</a>
<a class="govuk-link govuk-link--no-visited-state page-footer-secondary-link" href="{{ secondary_link }}">{{ secondary_link_text }}</a>
{% endif %}
</div>
{% endmacro %}
+1 -1
View File
@@ -13,7 +13,7 @@
<div class='pill-selected-item{% if not show_count %} pill-centered-item{% endif %}' tabindex='0'>
{% else %}
<li aria-selected='false' role='tab'>
<a href="{{ link }}">
<a class="govuk-link govuk-link--no-visited-state" href="{{ link }}">
{% endif %}
{% if show_count %}
{{ big_number(count, **big_number_args) }}
@@ -4,7 +4,7 @@
<ul class="group">
{% if previous_page %}
<li class="previous-page">
<a href="{{previous_page['url']}}" rel="previous" >
<a class="govuk-link govuk-link--no-visited-state" href="{{previous_page['url']}}" rel="previous" >
<span class="pagination-part-title">
<svg class="pagination-icon" xmlns="http://www.w3.org/2000/svg" height="13" width="17" viewBox="0 0 17 13">
<path fill="currentColor" d="m6.5938-0.0078125-6.7266 6.7266 6.7441 6.4062 1.377-1.449-4.1856-3.9768h12.896v-2h-12.984l4.2931-4.293-1.414-1.414z"/>
@@ -17,7 +17,7 @@
{% endif %}
{% if next_page %}
<li class="next-page">
<a href="{{next_page['url']}}" rel="next">
<a class="govuk-link govuk-link--no-visited-state" href="{{next_page['url']}}" rel="next">
<span class="pagination-part-title">
{{next_page['title']}}
<svg class="pagination-icon" xmlns="http://www.w3.org/2000/svg" height="13" width="17" viewBox="0 0 17 13">
@@ -0,0 +1,7 @@
{% macro service_link(service, endpoint, link_text) %}
{% if service %}
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for(endpoint, service_id=service.id) }}">{{ link_text }}</a>
{% else %}
<b class="govuk-!-font-weight-bold">{{ link_text|capitalize }}</b>
{% endif %}
{% endmacro %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% macro show_more(url, label, with_border=True) %}
<a
href="{{ url }}"
class="show-more{% if not with_border %}-no-border{% endif %}"
class="govuk-link govuk-link--no-visited-state show-more{% if not with_border %}-no-border{% endif %}"
><span>{{ label }}</span></a>
{% endmacro %}
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="big-number-with-status">
<div class="big-number-status{% if failing %}-failing{% endif %}">
{% if url %}
<a href="{{ url }}">{{ number }} {{ label }}</a>
<a class="govuk-link govuk-link--no-visited-state" href="{{ url }}">{{ number }} {{ label }}</a>
{% else %}
{{ number }} {{ label }}
{% endif %}
+1 -1
View File
@@ -4,7 +4,7 @@
itemscope
itemtype="http://schema.org/ListItem"
>
<a href="{{ url_for(item['link']) }}" itemprop="item">
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for(item['link']) }}" itemprop="item">
<span itemprop="name">{{item['name']}}</span>
</a>
</li>
+5 -4
View File
@@ -117,14 +117,14 @@
{% macro link_field(text, link) -%}
{% call field() %}
<a href="{{ link }}">{{ text }}</a>
<a class="govuk-link govuk-link--no-visited-state" href="{{ link }}">{{ text }}</a>
{% endcall %}
{%- endmacro %}
{% macro edit_field(text, link, permissions=[]) -%}
{% call field(align='right') %}
{% if not permissions or current_user.has_permissions(*permissions) %}
<a href="{{ link }}">{{ text }}</a>
<a class="govuk-link govuk-link--no-visited-state" href="{{ link }}">{{ text }}</a>
{% endif %}
{% endcall %}
{%- endmacro %}
@@ -149,13 +149,14 @@
{% if not notification %}
{% call field(align='right') %}{% endcall %}
{% else %}
{% set status = notification.status|format_notification_status_as_field_status(notification.notification_type) %}
{% call field(
status=notification.status|format_notification_status_as_field_status(notification.notification_type),
status=status,
align='right'
) %}
{% if displayed_on_single_line %}<span class="align-with-message-body">{% endif %}
{% if notification.status|format_notification_status_as_url(notification.notification_type) %}
<a href="{{ notification.status|format_notification_status_as_url(notification.notification_type) }}">
<a class="govuk-link govuk-link--{% if status == 'error' %}destructive{% else %}no-visited-state{% endif %}" href="{{ notification.status|format_notification_status_as_url(notification.notification_type) }}">
{% endif %}
{{ notification.status|format_notification_status(notification.template.template_type) }}
{% if notification.status|format_notification_status_as_url(notification.notification_type) %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% macro task_list_item(completed, label, link) %}
<li class="task-list-item">
<span aria-describedby="{{ label|id_safe }}"><a href="{{ link }}">{{ label }}</a></span>
<span aria-describedby="{{ label|id_safe }}"><a class="govuk-link govuk-link--no-visited-state" href="{{ link }}">{{ label }}</a></span>
{% if completed %}
<strong class="task-list-indicator-completed" id="{{ label|id_safe }}">Completed</strong>
{% else %}
+1 -1
View File
@@ -58,7 +58,7 @@
{% endif %}
{% if help_link and help_link_text %}
<p class="textbox-help-link">
<a href='{{ help_link }}'>{{ help_link_text }}</a>
<a class="govuk-link govuk-link--no-visited-state" href='{{ help_link }}'>{{ help_link_text }}</a>
</p>
{% endif %}
</div>
+4 -4
View File
@@ -8,15 +8,15 @@
{% block maincolumn_content %}
<div class="grid-row">
<div class="govuk-grid-row">
{% if navigation_links %}
<div class="column-one-quarter">
<div class="govuk-grid-column-one-quarter">
{{ sub_navigation(navigation_links) }}
</div>
<div class="column-five-eighths">
<div class="govuk-grid-column-five-eighths">
{% else %}
<div class="column-two-thirds">
<div class="govuk-grid-column-two-thirds">
{% endif %}
{% block content_column_content %}
-11
View File
@@ -1,11 +0,0 @@
{% extends "withoutnav_template.html" %}
{% block per_page_title %}Bad request{% endblock %}
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-medium">
{{ message|join(",")}}
</h1>
</div>
</div>
{% endblock %}
+3 -3
View File
@@ -1,9 +1,9 @@
{% extends "withoutnav_template.html" %}
{% block per_page_title %}Youre not authorised to see this page{% endblock %}
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1>Youre not authorised to see this page</h1>
<p><a href="{{ url_for('.sign_in' )}}">Sign in</a> to GOV.UK Notify and try again.</p>
<p><a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.sign_in' )}}">Sign in</a> to GOV.UK Notify and try again.</p>
</div>
{% endblock %}

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