diff --git a/.gitignore b/.gitignore index 508249609..9b8b7c764 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,6 @@ target/ .vscode # Frontend dependencies and compiled assets -app/assets/stylesheets/govuk_template/.sass-cache/ .sass-cache/ cache/ node_modules diff --git a/Makefile b/Makefile index 17cfe64fa..3e5415b44 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,6 @@ GIT_COMMIT ?= $(shell git rev-parse HEAD 2> /dev/null || echo "") DOCKER_IMAGE_TAG := $(shell cat docker/VERSION) DOCKER_BUILDER_IMAGE_NAME = govuk/notify-admin-builder:${DOCKER_IMAGE_TAG} -DOCKER_TTY ?= $(if ${JENKINS_HOME},,t) BUILD_TAG ?= notifications-admin-manual BUILD_NUMBER ?= 0 @@ -27,39 +26,13 @@ $(eval export CF_HOME) NOTIFY_CREDENTIALS ?= ~/.notify-credentials + +## DEVELOPMENT + .PHONY: help help: @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -.PHONY: check-env-vars -check-env-vars: ## Check mandatory environment variables - $(if ${DEPLOY_ENV},,$(error Must specify DEPLOY_ENV)) - $(if ${DNS_NAME},,$(error Must specify DNS_NAME)) - -.PHONY: sandbox -sandbox: ## Set environment to sandbox - $(eval export DEPLOY_ENV=sandbox) - $(eval export DNS_NAME="cloudapps.digital") - @true - -.PHONY: preview -preview: ## Set environment to preview - $(eval export DEPLOY_ENV=preview) - $(eval export DNS_NAME="notify.works") - @true - -.PHONY: staging -staging: ## Set environment to staging - $(eval export DEPLOY_ENV=staging) - $(eval export DNS_NAME="staging-notify.works") - @true - -.PHONY: production -production: ## Set environment to production - $(eval export DEPLOY_ENV=production) - $(eval export DNS_NAME="notifications.service.gov.uk") - @true - .PHONY: dependencies dependencies: ## Install build dependencies npm set progress=false @@ -76,22 +49,6 @@ build: dependencies generate-version-file ## Build project npm run build pip install -r requirements.txt -.PHONY: build-paas-artifact -build-paas-artifact: ## Build the deploy artifact for PaaS - rm -rf target - mkdir -p target - zip -y -q -r -x@deploy-exclude.lst target/notifications-admin.zip ./ - -.PHONY: upload-paas-artifact ## Upload the deploy artifact for PaaS -upload-paas-artifact: - $(if ${DEPLOY_BUILD_NUMBER},,$(error Must specify DEPLOY_BUILD_NUMBER)) - $(if ${JENKINS_S3_BUCKET},,$(error Must specify JENKINS_S3_BUCKET)) - aws s3 cp --region eu-west-1 --sse AES256 target/notifications-admin.zip s3://${JENKINS_S3_BUCKET}/build/notifications-admin/${DEPLOY_BUILD_NUMBER}.zip - -.PHONY: upload-static ## Upload the static files to be served from S3 -upload-static: - aws s3 cp --region eu-west-1 --recursive --cache-control max-age=315360000,immutable ./app/static s3://${DNS_NAME}-static - .PHONY: test test: ## Run tests ./scripts/run_tests.sh @@ -124,7 +81,7 @@ prepare-docker-build-image: ## Prepare the Docker builder image make -C docker build define run_docker_container - @docker run -i${DOCKER_TTY} --rm \ + @docker run -it --rm \ --name "${DOCKER_CONTAINER_PREFIX}-${1}" \ -v "`pwd`:/var/project" \ -e UID=$(shell id -u) \ @@ -167,6 +124,32 @@ clean-docker-containers: ## Clean up any remaining docker containers clean: rm -rf node_modules cache target + +## DEPLOYMENT + +.PHONY: check-env-vars +check-env-vars: ## Check mandatory environment variables + $(if ${DEPLOY_ENV},,$(error Must specify DEPLOY_ENV)) + $(if ${DNS_NAME},,$(error Must specify DNS_NAME)) + +.PHONY: preview +preview: ## Set environment to preview + $(eval export DEPLOY_ENV=preview) + $(eval export DNS_NAME="notify.works") + @true + +.PHONY: staging +staging: ## Set environment to staging + $(eval export DEPLOY_ENV=staging) + $(eval export DNS_NAME="staging-notify.works") + @true + +.PHONY: production +production: ## Set environment to production + $(eval export DEPLOY_ENV=production) + $(eval export DNS_NAME="notifications.service.gov.uk") + @true + .PHONY: cf-login cf-login: ## Log in to Cloud Foundry $(if ${CF_USERNAME},,$(error Must specify CF_USERNAME)) @@ -188,6 +171,10 @@ generate-manifest: --format=yaml \ <(${DECRYPT_CMD} ${NOTIFY_CREDENTIALS}/credentials/${CF_SPACE}/paas/environment-variables.gpg) 2>&1 +.PHONY: upload-static ## Upload the static files to be served from S3 +upload-static: + aws s3 cp --region eu-west-1 --recursive --cache-control max-age=315360000,immutable ./app/static s3://${DNS_NAME}-static + .PHONY: cf-deploy cf-deploy: ## Deploys the app to Cloud Foundry $(if ${CF_SPACE},,$(error Must specify CF_SPACE)) @@ -196,7 +183,7 @@ cf-deploy: ## Deploys the app to Cloud Foundry cf v3-cancel-zdt-push ${CF_APP} || true cf v3-apply-manifest ${CF_APP} -f <(make -s generate-manifest) - cf v3-zdt-push ${CF_APP} --wait-for-deploy-complete # fails after 5 mins if deploy doesn't work + CF_STARTUP_TIMEOUT=10 cf v3-zdt-push ${CF_APP} --wait-for-deploy-complete # fails after 5 mins if deploy doesn't work .PHONY: cf-deploy-prototype cf-deploy-prototype: cf-target ## Deploys the first prototype to Cloud Foundry @@ -210,10 +197,6 @@ cf-deploy-prototype-2: cf-target ## Deploys the second prototype to Cloud Foundr cf-rollback: cf-target ## Rollbacks the app to the previous release cf v3-cancel-zdt-push ${CF_APP} -.PHONY: cf-push -cf-push: - cf push -f <(make -s generate-manifest) - .PHONY: cf-target cf-target: check-env-vars @cf target -o ${CF_ORG} -s ${CF_SPACE} diff --git a/README.md b/README.md index c9eeb68ae..e175275dc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -[![Requirements Status](https://requires.io/github/alphagov/notifications-admin/requirements.svg?branch=master)](https://requires.io/github/alphagov/notifications-admin/requirements/?branch=master) -[![Coverage Status](https://coveralls.io/repos/alphagov/notifications-admin/badge.svg?branch=master&service=github)](https://coveralls.io/github/alphagov/notifications-admin?branch=master) - - # notifications-admin GOV.UK Notify admin application. diff --git a/app/__init__.py b/app/__init__.py index 4daf81b80..759b82b11 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,11 +1,11 @@ -import itertools import os +import re import urllib from datetime import datetime, timedelta, timezone from functools import partial from time import monotonic -import ago +import humanize import jinja2 from flask import ( Markup, @@ -13,6 +13,7 @@ from flask import ( flash, g, make_response, + redirect, render_template, request, session, @@ -59,9 +60,11 @@ from app.navigation import ( MainNavigation, OrgNavigation, ) +from app.notify_client import InviteTokenError from app.notify_client.api_key_api_client import api_key_api_client from app.notify_client.billing_api_client import billing_api_client from app.notify_client.complaint_api_client import complaint_api_client +from app.notify_client.contact_list_api_client import contact_list_api_client from app.notify_client.email_branding_client import email_branding_client from app.notify_client.events_api_client import events_api_client from app.notify_client.inbound_number_client import inbound_number_client @@ -89,6 +92,7 @@ from app.url_converters import ( LetterFileExtensionConverter, SimpleDateTypeConverter, TemplateTypeConverter, + TicketTypeConverter, ) from app.utils import format_thousands, get_logo_cdn_domain, id_safe @@ -138,6 +142,7 @@ def create_app(application): # API clients api_key_api_client, billing_api_client, + contact_list_api_client, complaint_api_client, email_branding_client, events_api_client, @@ -223,6 +228,7 @@ def init_app(application): application.url_map.converters['uuid'].to_python = lambda self, value: value application.url_map.converters['template_type'] = TemplateTypeConverter + application.url_map.converters['ticket_type'] = TicketTypeConverter application.url_map.converters['letter_file_extension'] = LetterFileExtensionConverter application.url_map.converters['simple_date'] = SimpleDateTypeConverter @@ -291,7 +297,7 @@ def format_time_24h(date): return utc_string_to_aware_gmt_datetime(date).strftime('%H:%M') -def get_human_day(time): +def get_human_day(time, date_prefix=''): # Add 1 minute to transform 00:00 into ‘midnight today’ instead of ‘midnight tomorrow’ date = (utc_string_to_aware_gmt_datetime(time) - timedelta(minutes=1)).date() @@ -304,8 +310,15 @@ def get_human_day(time): if date == (now - timedelta(days=1)).date(): return 'yesterday' if date.strftime('%Y') != now.strftime('%Y'): - return '{} {}'.format(_format_datetime_short(date), date.strftime('%Y')) - return _format_datetime_short(date) + return '{} {} {}'.format( + date_prefix, + _format_datetime_short(date), + date.strftime('%Y'), + ).strip() + return '{} {}'.format( + date_prefix, + _format_datetime_short(date), + ).strip() def format_time(date): @@ -334,10 +347,29 @@ def format_date_human(date): return get_human_day(date) +def format_datetime_human(date, date_prefix=''): + return '{} at {}'.format( + get_human_day(date, date_prefix='on'), + format_time(date), + ) + + +def format_day_of_week(date): + return utc_string_to_aware_gmt_datetime(date).strftime('%A') + + def _format_datetime_short(datetime): return datetime.strftime('%d %B').lstrip('0') +def naturaltime_without_indefinite_article(date): + return re.sub( + 'an? (.*) ago', + lambda match: '1 {} ago'.format(match.group(1)), + humanize.naturaltime(date), + ) + + def format_delta(date): delta = ( datetime.now(timezone.utc) @@ -348,12 +380,17 @@ def format_delta(date): return "just now" if delta < timedelta(seconds=60): return "in the last minute" - return ago.human( - delta, - future_tense='{} from now', # No-one should ever see this - past_tense='{} ago', - precision=1 - ) + return naturaltime_without_indefinite_article(delta) + + +def format_delta_days(date): + now = datetime.now(timezone.utc) + date = utc_string_to_aware_gmt_datetime(date) + if date.strftime('%Y-%M-%D') == now.strftime('%Y-%M-%D'): + return "today" + if date.strftime('%Y-%M-%D') == (now - timedelta(days=1)).strftime('%Y-%M-%D'): + return "yesterday" + return naturaltime_without_indefinite_article(now - date) def valid_phone_number(phone_number): @@ -584,8 +621,10 @@ def useful_headers_after_request(response): def register_errorhandlers(application): # noqa (C901 too complex) - def _error_response(error_code): - resp = make_response(render_template("error/{0}.html".format(error_code)), error_code) + def _error_response(error_code, error_page_template=None): + if error_page_template is None: + error_page_template = error_code + resp = make_response(render_template("error/{0}.html".format(error_page_template)), error_code) return useful_headers_after_request(resp) @application.errorhandler(HTTPError) @@ -596,15 +635,10 @@ def register_errorhandlers(application): # noqa (C901 too complex) error.message )) error_code = error.status_code - if error_code == 400: - if isinstance(error.message, str): - msg = [error.message] - else: - msg = list(itertools.chain(*[error.message[x] for x in error.message.keys()])) - resp = make_response(render_template("error/400.html", message=msg)) - return useful_headers_after_request(resp) - elif error_code not in [401, 404, 403, 410]: - # probably a 500 or 503 + if error_code not in [401, 404, 403, 410]: + # probably a 500 or 503. + # it might be a 400, which we should handle as if it's an internal server error. If the API might + # legitimately return a 400, we should handle that within the view or the client that calls it. application.logger.exception("API {} failed with status {} message {}".format( error.response.url if error.response else 'unknown', error.status_code, @@ -614,8 +648,10 @@ def register_errorhandlers(application): # noqa (C901 too complex) return _error_response(error_code) @application.errorhandler(400) - def handle_400(error): - return _error_response(400) + def handle_client_error(error): + # This is tripped if we call `abort(400)`. + application.logger.exception('Unhandled 400 client error') + return _error_response(400, error_page_template=500) @application.errorhandler(410) def handle_gone(error): @@ -658,19 +694,11 @@ def register_errorhandlers(application): # noqa (C901 too complex) u'csrf.invalid_token: Aborting request, user_id: {user_id}', extra={'user_id': session['user_id']}) - resp = make_response(render_template( - "error/400.html", - message=['Something went wrong, please go back and try again.'] - ), 400) - return useful_headers_after_request(resp) + return _error_response(400, error_page_template=500) @application.errorhandler(405) - def handle_405(error): - resp = make_response(render_template( - "error/400.html", - message=['Something went wrong, please go back and try again.'] - ), 405) - return useful_headers_after_request(resp) + def handle_method_not_allowed(error): + return _error_response(405, error_page_template=500) @application.errorhandler(WerkzeugHTTPException) def handle_http_error(error): @@ -680,6 +708,11 @@ def register_errorhandlers(application): # noqa (C901 too complex) return _error_response(error.code) + @application.errorhandler(InviteTokenError) + def handle_bad_invite_token(error): + flash(str(error)) + return redirect(url_for('main.sign_in')) + @application.errorhandler(500) @application.errorhandler(Exception) def handle_bad_request(error): @@ -742,8 +775,11 @@ def add_template_filters(application): format_date_human, format_date_normal, format_date_short, + format_datetime_human, format_datetime_relative, + format_day_of_week, format_delta, + format_delta_days, format_notification_status, format_notification_type, format_notification_status_as_time, diff --git a/app/assets/error_pages/5xx.html b/app/assets/error_pages/5xx.html index 1f802e608..8fd9ef8bd 100644 --- a/app/assets/error_pages/5xx.html +++ b/app/assets/error_pages/5xx.html @@ -75,7 +75,7 @@ Try again later.

- You can check our system status page to see if there are any known issues.
To report a problem, email notify-support@digital.cabinet-office.gov.uk + You can check our system status page to see if there are any known issues.
To report a problem, email notify-support@digital.cabinet-office.gov.uk diff --git a/app/assets/javascripts/analytics/analytics.js b/app/assets/javascripts/analytics/analytics.js index 711f6ec80..a776f93a2 100644 --- a/app/assets/javascripts/analytics/analytics.js +++ b/app/assets/javascripts/analytics/analytics.js @@ -15,6 +15,7 @@ window.ga('set', 'anonymizeIp', config.anonymizeIp); window.ga('set', 'allowAdFeatures', config.allowAdFeatures); window.ga('set', 'transport', config.transport); + window.ga('set', 'title', 'GOV.UK Notify'); }; diff --git a/app/assets/javascripts/enhancedTextbox.js b/app/assets/javascripts/enhancedTextbox.js index 8adb210f8..a4965857a 100644 --- a/app/assets/javascripts/enhancedTextbox.js +++ b/app/assets/javascripts/enhancedTextbox.js @@ -27,6 +27,8 @@ `)) .on("input", this.update); + $(window).on("resize", this.resize); + visibleTextbox = this.$textbox.clone().appendTo("body").css({ position: 'absolute', visibility: 'hidden', @@ -47,7 +49,7 @@ this.resize = () => { - this.$background.width(this.$textbox.outerWidth()); + this.$background.width(this.$textbox.width()); this.$textbox.height( Math.max( diff --git a/app/assets/javascripts/previewPane.js b/app/assets/javascripts/previewPane.js index 514b02274..2dac47421 100644 --- a/app/assets/javascripts/previewPane.js +++ b/app/assets/javascripts/previewPane.js @@ -10,7 +10,7 @@ branding_style = branding_style.val(); - const $paneWrapper = $('

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