mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-09-11 10:28:41 -04:00
Merge branch 'master' into itsdangerous-upgrade
This commit is contained in:
@@ -62,7 +62,6 @@ target/
|
||||
.vscode
|
||||
|
||||
# Frontend dependencies and compiled assets
|
||||
app/assets/stylesheets/govuk_template/.sass-cache/
|
||||
.sass-cache/
|
||||
cache/
|
||||
node_modules
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
[](https://requires.io/github/alphagov/notifications-admin/requirements/?branch=master)
|
||||
[](https://coveralls.io/github/alphagov/notifications-admin?branch=master)
|
||||
|
||||
|
||||
# notifications-admin
|
||||
|
||||
GOV.UK Notify admin application.
|
||||
|
||||
+71
-35
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
&__heading {
|
||||
display: block;
|
||||
margin-bottom: $gutter-half;
|
||||
margin-bottom: govuk-spacing(3);
|
||||
|
||||
&::before {
|
||||
top: -1.3em;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -4,6 +4,7 @@ from itertools import chain
|
||||
|
||||
import pytz
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm as Form
|
||||
from flask_wtf.file import FileAllowed
|
||||
from flask_wtf.file import FileField as FileField_wtf
|
||||
@@ -42,10 +43,12 @@ from app.main.validators import (
|
||||
LettersNumbersAndFullStopsOnly,
|
||||
MustContainAlphanumericCharacters,
|
||||
NoCommasInPlaceHolders,
|
||||
NoEmbeddedImagesInSVG,
|
||||
OnlySMSCharacters,
|
||||
ValidEmail,
|
||||
ValidGovEmail,
|
||||
)
|
||||
from app.models.feedback import PROBLEM_TICKET_TYPE, QUESTION_TICKET_TYPE
|
||||
from app.models.organisation import Organisation
|
||||
from app.models.roles_and_permissions import permissions, roles
|
||||
from app.utils import guess_name_from_email_address
|
||||
@@ -499,7 +502,7 @@ class InviteUserForm(PermissionsForm):
|
||||
self.invalid_email_address = invalid_email_address.lower()
|
||||
|
||||
def validate_email_address(self, field):
|
||||
if field.data.lower() == self.invalid_email_address:
|
||||
if field.data.lower() == self.invalid_email_address and not current_user.platform_admin:
|
||||
raise ValidationError("You cannot send an invitation to yourself")
|
||||
|
||||
|
||||
@@ -511,7 +514,7 @@ class InviteOrgUserForm(StripWhitespaceForm):
|
||||
self.invalid_email_address = invalid_email_address.lower()
|
||||
|
||||
def validate_email_address(self, field):
|
||||
if field.data.lower() == self.invalid_email_address:
|
||||
if field.data.lower() == self.invalid_email_address and not current_user.platform_admin:
|
||||
raise ValidationError("You cannot send an invitation to yourself")
|
||||
|
||||
|
||||
@@ -883,23 +886,30 @@ class SupportType(StripWhitespaceForm):
|
||||
support_type = RadioField(
|
||||
'How can we help you?',
|
||||
choices=[
|
||||
('report-problem', 'Report a problem'),
|
||||
('ask-question-give-feedback', 'Ask a question or give feedback'),
|
||||
(PROBLEM_TICKET_TYPE, 'Report a problem'),
|
||||
(QUESTION_TICKET_TYPE, 'Ask a question or give feedback'),
|
||||
],
|
||||
validators=[DataRequired()]
|
||||
)
|
||||
|
||||
|
||||
class Feedback(StripWhitespaceForm):
|
||||
name = StringField('Name')
|
||||
email_address = email_address(label='Email address', gov_user=False, required=False)
|
||||
class SupportRedirect(StripWhitespaceForm):
|
||||
who = RadioField(
|
||||
'What do you need help with?',
|
||||
choices=[
|
||||
('public-sector', 'I work in the public sector and need to send emails, text messages or letters'),
|
||||
('public', 'I’m a member of the public with a question for the government'),
|
||||
],
|
||||
validators=[DataRequired()]
|
||||
)
|
||||
|
||||
|
||||
class FeedbackOrProblem(StripWhitespaceForm):
|
||||
name = StringField('Name (optional)')
|
||||
email_address = email_address(label='Email address', gov_user=False, required=True)
|
||||
feedback = TextAreaField('Your message', validators=[DataRequired(message="Cannot be empty")])
|
||||
|
||||
|
||||
class Problem(Feedback):
|
||||
email_address = email_address(label='Email address', gov_user=False)
|
||||
|
||||
|
||||
class Triage(StripWhitespaceForm):
|
||||
severe = RadioField(
|
||||
'Is it an emergency?',
|
||||
@@ -1133,7 +1143,8 @@ class SVGFileUpload(StripWhitespaceForm):
|
||||
'Upload an SVG logo',
|
||||
validators=[
|
||||
FileAllowed(['svg'], 'SVG Images only!'),
|
||||
DataRequired(message="You need to upload a file to submit")
|
||||
DataRequired(message="You need to upload a file to submit"),
|
||||
NoEmbeddedImagesInSVG()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
+14
-1
@@ -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)))
|
||||
|
||||
@@ -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
@@ -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 there’s 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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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("""
|
||||
You’re signed in as {}.
|
||||
|
||||
+6
-12
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
QUESTION_TICKET_TYPE = 'ask-question-give-feedback'
|
||||
PROBLEM_TICKET_TYPE = 'report-problem'
|
||||
GENERAL_TICKET_TYPE = 'general'
|
||||
+6
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends "withoutnav_template.html" %}
|
||||
{% block per_page_title %}You’re 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>You’re 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
Reference in New Issue
Block a user