diff --git a/Makefile b/Makefile index e255be409..b45f3fbd0 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,6 @@ BUILD_URL ?= DOCKER_CONTAINER_PREFIX = ${USER}-${BUILD_TAG} -CODEDEPLOY_PREFIX ?= notifications-admin -CODEDEPLOY_APP_NAME ?= notify-admin - CF_API ?= api.cloud.service.gov.uk CF_ORG ?= govuk-notify CF_SPACE ?= ${DEPLOY_ENV} @@ -43,8 +40,6 @@ venv/bin/activate: check-env-vars: ## Check mandatory environment variables $(if ${DEPLOY_ENV},,$(error Must specify DEPLOY_ENV)) $(if ${DNS_NAME},,$(error Must specify DNS_NAME)) - $(if ${AWS_ACCESS_KEY_ID},,$(error Must specify AWS_ACCESS_KEY_ID)) - $(if ${AWS_SECRET_ACCESS_KEY},,$(error Must specify AWS_SECRET_ACCESS_KEY)) .PHONY: sandbox sandbox: ## Set environment to sandbox @@ -87,55 +82,22 @@ build: dependencies generate-version-file ## Build project npm run build . venv/bin/activate && PIP_ACCEL_CACHE=${PIP_ACCEL_CACHE} pip-accel install -r requirements.txt -.PHONY: cf-build -cf-build: dependencies generate-version-file ## Build project - npm run build - -.PHONY: build-codedeploy-artifact -build-codedeploy-artifact: ## Build the deploy artifact for CodeDeploy +.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-codedeploy-artifact ## Upload the deploy artifact for CodeDeploy -upload-codedeploy-artifact: check-env-vars - $(if ${DEPLOY_BUILD_NUMBER},,$(error Must specify DEPLOY_BUILD_NUMBER)) - aws s3 cp --region eu-west-1 --sse AES256 target/notifications-admin.zip s3://${DNS_NAME}-codedeploy/${CODEDEPLOY_PREFIX}-${DEPLOY_BUILD_NUMBER}.zip - -.PHONY: build-paas-artifact -build-paas-artifact: build-codedeploy-artifact ## Build the deploy artifact for PaaS - .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/${CODEDEPLOY_PREFIX}/${DEPLOY_BUILD_NUMBER}.zip + 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: test test: venv ## Run tests ./scripts/run_tests.sh -.PHONY: deploy -deploy: check-env-vars ## Upload deploy artifacts to S3 and trigger CodeDeploy - aws deploy create-deployment --application-name ${CODEDEPLOY_APP_NAME} --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name ${CODEDEPLOY_APP_NAME} --s3-location bucket=${DNS_NAME}-codedeploy,key=${CODEDEPLOY_PREFIX}-${DEPLOY_BUILD_NUMBER}.zip,bundleType=zip --region eu-west-1 - -.PHONY: check-aws-vars -check-aws-vars: ## Check if AWS access keys are set - $(if ${AWS_ACCESS_KEY_ID},,$(error Must specify AWS_ACCESS_KEY_ID)) - $(if ${AWS_SECRET_ACCESS_KEY},,$(error Must specify AWS_SECRET_ACCESS_KEY)) - -.PHONY: deploy-suspend-autoscaling-processes -deploy-suspend-autoscaling-processes: check-aws-vars ## Suspend launch and terminate processes for the auto-scaling group - aws autoscaling suspend-processes --region eu-west-1 --auto-scaling-group-name ${CODEDEPLOY_APP_NAME} --scaling-processes "Launch" "Terminate" - -.PHONY: deploy-resume-autoscaling-processes -deploy-resume-autoscaling-processes: check-aws-vars ## Resume launch and terminate processes for the auto-scaling group - aws autoscaling resume-processes --region eu-west-1 --auto-scaling-group-name ${CODEDEPLOY_APP_NAME} --scaling-processes "Launch" "Terminate" - -.PHONY: deploy-check-autoscaling-processes -deploy-check-autoscaling-processes: check-aws-vars ## Returns with the number of instances with active autoscaling events - @aws autoscaling describe-auto-scaling-groups --region eu-west-1 --auto-scaling-group-names ${CODEDEPLOY_APP_NAME} | jq '.AutoScalingGroups[0].Instances|map(select(.LifecycleState != "InService"))|length' - .PHONY: coverage coverage: venv ## Create coverage report . venv/bin/activate && coveralls @@ -180,10 +142,6 @@ endef build-with-docker: prepare-docker-build-image ## Build inside a Docker container $(call run_docker_container,build,gosu hostuser make build) -.PHONY: cf-build-with-docker -cf-build-with-docker: prepare-docker-build-image ## Build inside a Docker container - $(call run_docker_container,build,gosu hostuser make cf-build) - .PHONY: test-with-docker test-with-docker: prepare-docker-build-image ## Run tests inside a Docker container $(call run_docker_container,test,gosu hostuser make test) @@ -220,9 +178,8 @@ cf-deploy: ## Deploys the app to Cloud Foundry cf delete -f notify-admin-rollback .PHONY: cf-deploy-prototype -cf-deploy-prototype: ## Deploys the app to Cloud Foundry +cf-deploy-prototype: cf-target ## Deploys the app to Cloud Foundry $(if ${CF_SPACE},,$(error Must specify CF_SPACE)) - cf target -s ${CF_SPACE} cf push -f manifest-prototype-${CF_SPACE}.yml .PHONY: cf-rollback @@ -235,3 +192,23 @@ cf-rollback: ## Rollbacks the app to the previous release .PHONY: cf-push cf-push: cf push -f manifest-${CF_SPACE}.yml + +.PHONY: cf-target +cf-target: check-env-vars + @cf target -o ${CF_ORG} -s ${CF_SPACE} + +.PHONY: cf-failwhale-deployed +cf-failwhale-deployed: + @cf app notify-admin-failwhale --guid || (echo "notify-admin-failwhale is not deployed on ${CF_SPACE}" && exit 1) + +.PHONY: enable-failwhale +enable-failwhale: cf-target cf-failwhale-deployed ## Enable the failwhale app and disable admin + @cf map-route notify-admin-failwhale ${DNS_NAME} --hostname www + @cf unmap-route notify-admin ${DNS_NAME} --hostname www + @echo "Failwhale is enabled" + +.PHONY: disable-failwhale +disable-failwhale: cf-target cf-failwhale-deployed ## Disable the failwhale app and enable admin + @cf map-route notify-admin ${DNS_NAME} --hostname www + @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www + @echo "Failwhale is disabled" diff --git a/app/assets/javascripts/fullscreenTable.js b/app/assets/javascripts/fullscreenTable.js new file mode 100644 index 000000000..27ff52636 --- /dev/null +++ b/app/assets/javascripts/fullscreenTable.js @@ -0,0 +1,107 @@ +(function(Modules) { + "use strict"; + + Modules.FullscreenTable = function() { + + this.start = function(component) { + + this.$component = $(component); + this.$table = this.$component.find('table'); + this.nativeHeight = this.$component.innerHeight() + 20; // 20px to allow room for scrollbar + this.topOffset = this.$component.offset().top; + + this.insertShims(); + this.maintainWidth(); + this.maintainHeight(); + this.toggleShadows(); + + $(window) + .on('scroll resize', this.maintainHeight) + .on('resize', this.maintainWidth); + + this.$scrollableTable + .on('scroll', this.toggleShadows) + .on('scroll', this.maintainHeight); + + if ( + window.GOVUK.stopScrollingAtFooter && + window.GOVUK.stopScrollingAtFooter.updateFooterTop + ) { + window.GOVUK.stopScrollingAtFooter.updateFooterTop(); + } + + }; + + this.insertShims = () => { + + this.$table.wrap('
'); + + this.$component + .append( + this.$component.find('.fullscreen-scrollable-table') + .clone() + .addClass('fullscreen-fixed-table') + .removeClass('fullscreen-scrollable-table') + .attr('role', 'presentation') + ) + .append( + '' + ) + .after( + $("").css({ + 'height': this.nativeHeight, + 'top': this.topOffset + }) + ); + + this.$scrollableTable = this.$component.find('.fullscreen-scrollable-table'); + this.$fixedTable = this.$component.find('.fullscreen-fixed-table'); + + }; + + this.maintainHeight = () => { + + let height = Math.min( + $(window).height() - this.topOffset + $('html, body').scrollTop(), + this.nativeHeight + ); + + this.$scrollableTable.outerHeight(height); + this.$fixedTable.outerHeight(height); + + }; + + this.maintainWidth = () => { + + let indexColumnWidth = this.$fixedTable.find('.table-field-index').outerWidth(); + + this.$scrollableTable + .css({ + 'width': this.$component.parent('main').width() - indexColumnWidth, + 'margin-left': indexColumnWidth + }); + + this.$fixedTable + .width(indexColumnWidth + 4); + + }; + + this.toggleShadows = () => { + + this.$fixedTable + .toggleClass( + 'fullscreen-scrolled-table', + this.$scrollableTable.scrollLeft() > 0 + ); + + this.$component.find('.fullscreen-right-shadow') + .toggleClass( + 'visible', + this.$scrollableTable.scrollLeft() < (this.$table.width() - this.$scrollableTable.width()) + ); + + }; + + }; + +})(window.GOVUK.Modules); diff --git a/app/assets/stylesheets/components/fullscreen-table.scss b/app/assets/stylesheets/components/fullscreen-table.scss new file mode 100644 index 000000000..b552fcbd8 --- /dev/null +++ b/app/assets/stylesheets/components/fullscreen-table.scss @@ -0,0 +1,134 @@ +.fullscreen { + + &-content { + + background: $white; + z-index: 10; + overflow-y: hidden; + box-sizing: border-box; + position: absolute; + margin: 5px 0 $gutter 0; + padding: 0 0 0 0; + overflow: hidden; + border-bottom: 1px solid $border-colour; + + .table { + + margin-bottom: 0; + + tr:last-child { + td { + border-bottom: 1px solid $white; + } + } + + } + + th, + .table-field-error-label, + .table-field-center-aligned { + white-space: nowrap; + } + + } + + &-right-shadow { + + position: absolute; + top: 0; + right: 0; + width: 4px; + height: 100%; + z-index: 200; + + &.visible { + transition: box-shadow 0.3s ease-in-out; + box-shadow: inset -1px 0 0 0 $border-colour, inset -3px 0 0 0 rgba($border-colour, 0.2); + } + + } + + &-scrollable-table { + + overflow-x: auto; + overflow-y: hidden; + + .table-field-heading-first, + .table-field-index { + display: none; + } + + .table-field-center-aligned { + position: relative; + z-index: 150; + background: $white; + } + + &::-webkit-scrollbar { + -webkit-appearance: none; + } + + &::-webkit-scrollbar:horizontal { + height: 11px; + background-color: $white; + } + + &::-webkit-scrollbar-thumb { + border-radius: 8px; + border: 2px solid $white; + background-color: rgba(0, 0, 0, .5); + } + + &::-webkit-scrollbar-track { + background-color: $white; + border-radius: 8px; + } + + } + + &-fixed-table { + + position: absolute; + top: 0; + overflow: hidden; + + .table-field-heading { + visibility: hidden; + } + + .table-field-center-aligned { + width: 0; + position: relative; + z-index: 100; + visibility: hidden; + } + + .table-field-heading-first, + .table-field-index { + transition: none; + position: relative; + z-index: 200; + background: $white; + } + + } + + &-scrolled-table { + + padding-bottom: 20px; + + .table-field-heading-first, + .table-field-index { + transition: box-shadow 0.3s ease-in-out; + box-shadow: 1px 0 0 0 $border-colour, 3px 0 0 0 rgba($border-colour, 0.2); + } + + } + + &-shim { + width: 100%; + position: relative; + z-index: 9; + } + +} 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 cd87884f1..77d076ff1 100644 --- a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss +++ b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss @@ -16,9 +16,24 @@ margin-bottom: 20px; } + .back-to-top-link { + + position: absolute; + top: $gutter; + right: $gutter-half; + opacity: 0; + transition: opacity 0.1s ease-in-out; + + @include ie-lte(8) { + display: none; + } + + } + } .content-fixed { + position: fixed; top: 0; background: $white; @@ -28,8 +43,15 @@ border-bottom: 1px solid $border-colour; box-shadow: 0 2px 0 0 rgba($border-colour, 0.2); transition: background 0.6s ease-in-out, margin-top 0.4s ease-out; + + .back-to-top-link { + opacity: 1; + transition: opacity 0.6s ease-in-out; + } + } .shim { display: block; + margin-bottom: 5px; } diff --git a/app/assets/stylesheets/components/table.scss b/app/assets/stylesheets/components/table.scss index c86d5cc12..5bd4c68c0 100644 --- a/app/assets/stylesheets/components/table.scss +++ b/app/assets/stylesheets/components/table.scss @@ -209,10 +209,9 @@ .table-show-more-link { @include core-16; color: $secondary-text-colour; - margin-top: -30px; margin-bottom: $gutter * 1.3333; border-bottom: 1px solid $border-colour; - padding: 0.75em 0 0.5625em 0; + padding: 10px 0 10px 0; text-align: center; } diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss index ec57bf390..d4a2315df 100644 --- a/app/assets/stylesheets/main.scss +++ b/app/assets/stylesheets/main.scss @@ -59,6 +59,7 @@ $path: '/static/images/'; @import 'components/letter'; @import 'components/live-search'; @import 'components/stick-at-top-when-scrolling'; +@import 'components/fullscreen-table'; @import 'components/vendor/breadcrumbs'; @import 'components/vendor/responsive-embed'; diff --git a/app/commands.py b/app/commands.py index c13dfee18..026be777a 100644 --- a/app/commands.py +++ b/app/commands.py @@ -4,7 +4,7 @@ from flask import current_app def list_routes(): """List URLs of all application routes.""" for rule in sorted(current_app.url_map.iter_rules(), key=lambda r: r.rule): - print("{:10} {}".format(", ".join(rule.methods - set(['OPTIONS', 'HEAD'])), rule.rule)) + print("{:10} {}".format(", ".join(rule.methods - set(['OPTIONS', 'HEAD'])), rule.rule)) # noqa def setup_commands(application): diff --git a/app/config.py b/app/config.py index d1a2e222c..3fe195776 100644 --- a/app/config.py +++ b/app/config.py @@ -96,6 +96,8 @@ class Config(object): r"acas\.org\.uk", r"gov\.wales", r"biglotteryfund\.org\.uk", + r"marinemanagement\.org\.uk", + r"britishmuseum\.org", ] LOGO_UPLOAD_BUCKET_NAME = 'public-logos-local' diff --git a/app/main/forms.py b/app/main/forms.py index ca5e9f1ba..ea89af61d 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1,8 +1,10 @@ import re import pytz +import weakref from flask_wtf import FlaskForm as Form from datetime import datetime, timedelta +from itertools import chain from notifications_utils.recipients import ( validate_phone_number, @@ -98,6 +100,12 @@ def email_address(label='Email address', gov_user=True): return EmailField(label, validators) +def strip_whitespace(value): + if value is not None and hasattr(value, 'strip'): + return value.strip() + return value + + class UKMobileNumber(TelField): def pre_validate(self, form): try: @@ -154,7 +162,31 @@ def organisation_type(): ) -class LoginForm(Form): +class StripWhitespaceForm(Form): + class Meta: + def bind_field(self, form, unbound_field, options): + # FieldList simply doesn't support filters. + # @see: https://github.com/wtforms/wtforms/issues/148 + no_filter_fields = (FieldList, PasswordField) + filters = [strip_whitespace] if not issubclass(unbound_field.field_class, no_filter_fields) else [] + filters += unbound_field.kwargs.get('filters', []) + bound = unbound_field.bind(form=form, filters=filters, **options) + bound.get_form = weakref.ref(form) # GC won't collect the form if we don't use a weakref + return bound + + +class StripWhitespaceStringField(StringField): + def __init__(self, label=None, **kwargs): + kwargs['filters'] = tuple(chain( + kwargs.get('filters', ()), + ( + strip_whitespace, + ), + )) + super(StringField, self).__init__(label, **kwargs) + + +class LoginForm(StripWhitespaceForm): email_address = StringField('Email address', validators=[ Length(min=5, max=255), DataRequired(message='Can’t be empty'), @@ -165,7 +197,7 @@ class LoginForm(Form): ]) -class RegisterUserForm(Form): +class RegisterUserForm(StripWhitespaceForm): name = StringField('Full name', validators=[DataRequired(message='Can’t be empty')]) email_address = email_address() @@ -175,7 +207,7 @@ class RegisterUserForm(Form): auth_type = HiddenField('auth_type', default='sms_auth') -class RegisterUserFromInviteForm(Form): +class RegisterUserFromInviteForm(StripWhitespaceForm): def __init__(self, invited_user): super().__init__( service=invited_user['service'], @@ -198,7 +230,7 @@ class RegisterUserFromInviteForm(Form): raise ValidationError('Can’t be empty') -class PermissionsForm(Form): +class PermissionsForm(StripWhitespaceForm): send_messages = BooleanField("Send messages from existing templates") manage_templates = BooleanField("Add and edit templates") manage_service = BooleanField("Modify this service and its team") @@ -225,7 +257,7 @@ class InviteUserForm(PermissionsForm): raise ValidationError("You can’t send an invitation to yourself") -class TwoFactorForm(Form): +class TwoFactorForm(StripWhitespaceForm): def __init__(self, validate_code_func, *args, **kwargs): ''' Keyword arguments: @@ -242,15 +274,15 @@ class TwoFactorForm(Form): raise ValidationError(reason) -class EmailNotReceivedForm(Form): +class EmailNotReceivedForm(StripWhitespaceForm): email_address = email_address() -class TextNotReceivedForm(Form): +class TextNotReceivedForm(StripWhitespaceForm): mobile_number = international_phone_number() -class RenameServiceForm(Form): +class RenameServiceForm(StripWhitespaceForm): name = StringField( u'Service name', validators=[ @@ -258,7 +290,7 @@ class RenameServiceForm(Form): ]) -class CreateServiceForm(Form): +class CreateServiceForm(StripWhitespaceForm): name = StringField( u'What’s your service called?', validators=[ @@ -267,11 +299,11 @@ class CreateServiceForm(Form): organisation_type = organisation_type() -class OrganisationTypeForm(Form): +class OrganisationTypeForm(StripWhitespaceForm): organisation_type = organisation_type() -class FreeSMSAllowance(Form): +class FreeSMSAllowance(StripWhitespaceForm): free_sms_allowance = IntegerField( 'Numbers of text message fragments per year', validators=[ @@ -280,7 +312,7 @@ class FreeSMSAllowance(Form): ) -class ConfirmPasswordForm(Form): +class ConfirmPasswordForm(StripWhitespaceForm): def __init__(self, validate_password_func, *args, **kwargs): self.validate_password_func = validate_password_func super(ConfirmPasswordForm, self).__init__(*args, **kwargs) @@ -292,7 +324,7 @@ class ConfirmPasswordForm(Form): raise ValidationError('Invalid password') -class BaseTemplateForm(Form): +class BaseTemplateForm(StripWhitespaceForm): name = StringField( u'Template name', validators=[DataRequired(message="Can’t be empty")]) @@ -341,15 +373,15 @@ class LetterTemplateForm(EmailTemplateForm): ) -class ForgotPasswordForm(Form): +class ForgotPasswordForm(StripWhitespaceForm): email_address = email_address(gov_user=False) -class NewPasswordForm(Form): +class NewPasswordForm(StripWhitespaceForm): new_password = password() -class ChangePasswordForm(Form): +class ChangePasswordForm(StripWhitespaceForm): def __init__(self, validate_password_func, *args, **kwargs): self.validate_password_func = validate_password_func super(ChangePasswordForm, self).__init__(*args, **kwargs) @@ -362,16 +394,16 @@ class ChangePasswordForm(Form): raise ValidationError('Invalid password') -class CsvUploadForm(Form): +class CsvUploadForm(StripWhitespaceForm): file = FileField('Add recipients', validators=[DataRequired( message='Please pick a file'), CsvFileValidator()]) -class ChangeNameForm(Form): +class ChangeNameForm(StripWhitespaceForm): new_name = StringField(u'Your name') -class ChangeEmailForm(Form): +class ChangeEmailForm(StripWhitespaceForm): def __init__(self, validate_email_func, *args, **kwargs): self.validate_email_func = validate_email_func super(ChangeEmailForm, self).__init__(*args, **kwargs) @@ -384,11 +416,11 @@ class ChangeEmailForm(Form): raise ValidationError("The email address is already in use") -class ChangeMobileNumberForm(Form): +class ChangeMobileNumberForm(StripWhitespaceForm): mobile_number = international_phone_number() -class ConfirmMobileNumberForm(Form): +class ConfirmMobileNumberForm(StripWhitespaceForm): def __init__(self, validate_code_func, *args, **kwargs): self.validate_code_func = validate_code_func super(ConfirmMobileNumberForm, self).__init__(*args, **kwargs) @@ -401,7 +433,7 @@ class ConfirmMobileNumberForm(Form): raise ValidationError(msg) -class ChooseTimeForm(Form): +class ChooseTimeForm(StripWhitespaceForm): def __init__(self, *args, **kwargs): super(ChooseTimeForm, self).__init__(*args, **kwargs) @@ -421,7 +453,7 @@ class ChooseTimeForm(Form): ) -class CreateKeyForm(Form): +class CreateKeyForm(StripWhitespaceForm): def __init__(self, existing_key_names=[], *args, **kwargs): self.existing_key_names = [x.lower() for x in existing_key_names] super(CreateKeyForm, self).__init__(*args, **kwargs) @@ -442,7 +474,7 @@ class CreateKeyForm(Form): raise ValidationError('A key with this name already exists') -class SupportType(Form): +class SupportType(StripWhitespaceForm): support_type = RadioField( 'How can we help you?', choices=[ @@ -453,7 +485,7 @@ class SupportType(Form): ) -class Feedback(Form): +class Feedback(StripWhitespaceForm): name = StringField('Name') email_address = StringField('Email address') feedback = TextAreaField('Your message', validators=[DataRequired(message="Can’t be empty")]) @@ -463,7 +495,7 @@ class Problem(Feedback): email_address = email_address(label='Email address', gov_user=False) -class Triage(Form): +class Triage(StripWhitespaceForm): severe = RadioField( 'Is it an emergency?', choices=[ @@ -474,7 +506,7 @@ class Triage(Form): ) -class RequestToGoLiveForm(Form): +class RequestToGoLiveForm(StripWhitespaceForm): mou = RadioField( ( 'Has your organisation accepted the GOV.UK Notify data sharing and financial ' @@ -513,16 +545,16 @@ class RequestToGoLiveForm(Form): ) -class ProviderForm(Form): +class ProviderForm(StripWhitespaceForm): priority = IntegerField('Priority', [validators.NumberRange(min=1, max=100, message="Must be between 1 and 100")]) -class ServiceReplyToEmailForm(Form): +class ServiceReplyToEmailForm(StripWhitespaceForm): email_address = email_address(label='Email reply to address') is_default = BooleanField("Make this email address the default") -class ServiceSmsSenderForm(Form): +class ServiceSmsSenderForm(StripWhitespaceForm): sms_sender = StringField( 'Text message sender', validators=[ @@ -537,11 +569,11 @@ class ServiceSmsSenderForm(Form): raise ValidationError('Use letters and numbers only') -class ServiceEditInboundNumberForm(Form): +class ServiceEditInboundNumberForm(StripWhitespaceForm): is_default = BooleanField("Make this text message sender the default") -class ServiceLetterContactBlockForm(Form): +class ServiceLetterContactBlockForm(StripWhitespaceForm): letter_contact_block = TextAreaField( validators=[ DataRequired(message="Can’t be empty"), @@ -558,7 +590,7 @@ class ServiceLetterContactBlockForm(Form): ) -class ServiceBrandingOrg(Form): +class ServiceBrandingOrg(StripWhitespaceForm): def __init__(self, organisations=[], *args, **kwargs): self.organisation.choices = organisations @@ -585,7 +617,7 @@ class ServiceBrandingOrg(Form): ) -class ServiceSelectOrg(Form): +class ServiceSelectOrg(StripWhitespaceForm): def __init__(self, organisations=[], *args, **kwargs): self.organisation.choices = organisations @@ -599,7 +631,7 @@ class ServiceSelectOrg(Form): ) -class ServiceManageOrg(Form): +class ServiceManageOrg(StripWhitespaceForm): name = StringField('Name') @@ -607,7 +639,7 @@ class ServiceManageOrg(Form): file = FileField_wtf('Upload a PNG logo', validators=[FileAllowed(['png'], 'PNG Images only!')]) -class LetterBranding(Form): +class LetterBranding(StripWhitespaceForm): def __init__(self, choices=[], *args, **kwargs): super().__init__(*args, **kwargs) @@ -621,7 +653,15 @@ class LetterBranding(Form): ) -class Whitelist(Form): +class EmailFieldInWhitelist(EmailField, StripWhitespaceStringField): + pass + + +class InternationalPhoneNumberInWhitelist(InternationalPhoneNumber, StripWhitespaceStringField): + pass + + +class Whitelist(StripWhitespaceForm): def populate(self, email_addresses, phone_numbers): for form_field, existing_whitelist in ( @@ -632,7 +672,7 @@ class Whitelist(Form): form_field[index].data = value email_addresses = FieldList( - EmailField( + EmailFieldInWhitelist( '', validators=[ Optional(), @@ -646,7 +686,7 @@ class Whitelist(Form): ) phone_numbers = FieldList( - InternationalPhoneNumber( + InternationalPhoneNumberInWhitelist( '', validators=[ Optional() @@ -659,13 +699,13 @@ class Whitelist(Form): ) -class DateFilterForm(Form): +class DateFilterForm(StripWhitespaceForm): start_date = DateField("Start Date", [validators.optional()]) end_date = DateField("End Date", [validators.optional()]) include_from_test_key = BooleanField("Include test keys", default="checked", false_values={"N"}) -class ChooseTemplateType(Form): +class ChooseTemplateType(StripWhitespaceForm): template_type = RadioField( 'What kind of template do you want to add?', @@ -685,17 +725,17 @@ class ChooseTemplateType(Form): ]) -class SearchTemplatesForm(Form): +class SearchTemplatesForm(StripWhitespaceForm): search = SearchField('Search by name') -class SearchNotificationsForm(Form): +class SearchNotificationsForm(StripWhitespaceForm): to = SearchField('Search by phone number or email address') -class PlaceholderForm(Form): +class PlaceholderForm(StripWhitespaceForm): pass @@ -704,7 +744,7 @@ class PasswordFieldShowHasContent(StringField): widget = widgets.PasswordInput(hide_value=False) -class ServiceInboundNumberForm(Form): +class ServiceInboundNumberForm(StripWhitespaceForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.inbound_number.choices = kwargs['inbound_number_choices'] @@ -717,18 +757,33 @@ class ServiceInboundNumberForm(Form): ) -class ServiceInboundApiForm(Form): - url = StringField("Callback URL", - validators=[DataRequired(message='Can’t be empty'), - Regexp(regex="^https.*", - message='Must be a valid https URL')] - ) - bearer_token = PasswordFieldShowHasContent("Bearer token", - validators=[DataRequired(message='Can’t be empty'), - Length(min=10, message='Must be at least 10 characters')]) +class ServiceReceiveMessagesCallbackForm(StripWhitespaceForm): + url = StringField( + "URL", + validators=[DataRequired(message='Can’t be empty'), + Regexp(regex="^https.*", message='Must be a valid https URL')] + ) + bearer_token = PasswordFieldShowHasContent( + "Bearer token", + validators=[DataRequired(message='Can’t be empty'), + Length(min=10, message='Must be at least 10 characters')] + ) -class InternationalSMSForm(Form): +class ServiceDeliveryStatusCallbackForm(StripWhitespaceForm): + url = StringField( + "URL", + validators=[DataRequired(message='Can’t be empty'), + Regexp(regex="^https.*", message='Must be a valid https URL')] + ) + bearer_token = PasswordFieldShowHasContent( + "Bearer token", + validators=[DataRequired(message='Can’t be empty'), + Length(min=10, message='Must be at least 10 characters')] + ) + + +class InternationalSMSForm(StripWhitespaceForm): enabled = RadioField( 'Send text messages to international phone numbers', choices=[ @@ -738,7 +793,7 @@ class InternationalSMSForm(Form): ) -class SMSPrefixForm(Form): +class SMSPrefixForm(StripWhitespaceForm): enabled = RadioField( '', choices=[ @@ -776,7 +831,7 @@ def get_placeholder_form_instance( ) -class SetSenderForm(Form): +class SetSenderForm(StripWhitespaceForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/app/main/validators.py b/app/main/validators.py index e183f3d89..709274dda 100644 --- a/app/main/validators.py +++ b/app/main/validators.py @@ -1,5 +1,5 @@ from wtforms import ValidationError -from notifications_utils.template import Template +from notifications_utils.field import Field from notifications_utils.gsm import get_non_gsm_compatible_characters from app import formatted_list @@ -45,11 +45,11 @@ class ValidGovEmail: class NoCommasInPlaceHolders: - def __init__(self, message='You can’t have commas in your fields'): + def __init__(self, message='You can’t put commas between double brackets'): self.message = message def __call__(self, form, field): - if ',' in ''.join(Template({'content': field.data}).placeholders): + if ',' in ''.join(Field(field.data).placeholders): raise ValidationError(self.message) diff --git a/app/main/views/api_keys.py b/app/main/views/api_keys.py index 14d31bb65..d8e6225f7 100644 --- a/app/main/views/api_keys.py +++ b/app/main/views/api_keys.py @@ -1,18 +1,30 @@ from flask import request, render_template, redirect, url_for, flash, Markup, abort -from flask_login import login_required +from flask_login import login_required, current_user from app.main import main -from app.main.forms import CreateKeyForm, Whitelist +from app.main.forms import ( + CreateKeyForm, + Whitelist, + ServiceReceiveMessagesCallbackForm, + ServiceDeliveryStatusCallbackForm +) from app import api_key_api_client, service_api_client, notification_api_client, current_service from app.utils import user_has_permissions, email_safe from app.notify_client.api_key_api_client import KEY_TYPE_NORMAL, KEY_TYPE_TEST, KEY_TYPE_TEAM +dummy_bearer_token = 'bearer_token_set' + @main.route("/services/- Download this report + Download this report {{ time_left }}
diff --git a/app/templates/views/api/callbacks.html b/app/templates/views/api/callbacks.html new file mode 100644 index 000000000..54a86bafe --- /dev/null +++ b/app/templates/views/api/callbacks.html @@ -0,0 +1,33 @@ +{% extends "withnav_template.html" %} +{% from "components/textbox.html" import textbox %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/table.html" import mapping_table, row, text_field, edit_field, optional_text_field %} + + +{% block service_page_title %} + Callbacks +{% endblock %} + +{% block maincolumn_content %} +- Text messages you receive can be forwarded to your systems with our callback feature. - See our documentation on the format of the callback. + When you send an email or text message, we can tell you if Notify was able to deliver it. + Check the callback documentation for more information.
diff --git a/app/templates/views/api/callbacks/received-text-messages-callback.html b/app/templates/views/api/callbacks/received-text-messages-callback.html new file mode 100644 index 000000000..0ac44c3c6 --- /dev/null +++ b/app/templates/views/api/callbacks/received-text-messages-callback.html @@ -0,0 +1,39 @@ +{% extends "withnav_template.html" %} +{% from "components/textbox.html" import textbox %} +{% from "components/page-footer.html" import page_footer %} + +{% block service_page_title %} + Callbacks for received text messages +{% endblock %} + +{% block maincolumn_content %} ++ When you receive a text message in Notify, we can forward it to your system. + Check the callback documentation for more information. +
+ + +- Text messages you receive can be forwarded to a URL that you specify, using our callback feature. -
+A callback lets you receive messages from Notify to a URL you choose.
+You’ll need to provide a bearer token, for security. We’ll add this to the authorisation header of the callback request.
+The callback message is in JSON.
-- Messages are forwarded as they are received. -
- -- To protect your service, we require you to provide a bearer token. We put this token in the authorisation header of the callback requests. -
- -- Once you have ‘receive text messages’ enabled, you can set up your callback on the settings page of your service. -
- -- If you don’t have ‘receive text messages’ enabled for your service, get in touch and we can turn it on for you. -
- -- The format of the callback message you receive is JSON. -
+When you send an email or text message through Notify, we can send a receipt to your callback URL to tell you if we were able to deliver it or not.
+If your service receives text messages in Notify, we can forward them to your callback URL as soon as they arrive.
- It needs at least one row of data, and {{ recipients.missing_column_headers | sort() | formatted_list( - prefix='a column called', - prefix_plural='columns called' - ) }}. -
+ {% if recipients.missing_column_headers %} ++ It needs at least one row of data, and {{ recipients.missing_column_headers | sort() | formatted_list( + prefix='a column called', + prefix_plural='columns called' + ) }}. +
+ {% else %} ++ It needs at least one row of data. +
+ {% endif %} {% elif not recipients.has_recipient_columns %} @@ -108,54 +114,58 @@ {% endcall %}diff --git a/app/templates/views/check/ok.html b/app/templates/views/check/ok.html index affa5a4ff..85f79b97b 100644 --- a/app/templates/views/check/ok.html +++ b/app/templates/views/check/ok.html @@ -39,7 +39,7 @@ {% if template.template_type != 'letter' or not request.args.from_test %} {% else %} - Download as a printable PDF + Download as a printable PDF {% endif %} Back @@ -49,42 +49,40 @@
- {% if row_errors and not recipients.missing_column_headers %} - Only showing the first {{ count_of_displayed_recipients }} rows with errors - {% else %} - Only showing the first {{ count_of_displayed_recipients }} rows - {% endif %} + Only showing the first {{ count_of_displayed_recipients }} rows
{% endif %} diff --git a/app/templates/views/check/row-errors.html b/app/templates/views/check/row-errors.html index 537710516..e92647886 100644 --- a/app/templates/views/check/row-errors.html +++ b/app/templates/views/check/row-errors.html @@ -19,7 +19,7 @@ {% block maincolumn_content %} -{% if row_errors and not recipients.missing_column_headers %} diff --git a/app/templates/views/dashboard/_inbox_messages.html b/app/templates/views/dashboard/_inbox_messages.html index b46b16a8c..bba1e741d 100644 --- a/app/templates/views/dashboard/_inbox_messages.html +++ b/app/templates/views/dashboard/_inbox_messages.html @@ -4,7 +4,7 @@
- Download these messages + Download these messages
{% endif %} {% call(item, row_number) list_table( diff --git a/app/templates/views/notifications/check.html b/app/templates/views/notifications/check.html index 19c5d34e4..a58eee691 100644 --- a/app/templates/views/notifications/check.html +++ b/app/templates/views/notifications/check.html @@ -53,7 +53,7 @@ {% if template.template_type != 'letter' or not request.args.from_test %} {% else %} - Download as a printable PDF + Download as a printable PDF {% endif %} {% endif %} Back diff --git a/app/templates/views/notifications/notification.html b/app/templates/views/notifications/notification.html index f0ef58dba..f50a3c634 100644 --- a/app/templates/views/notifications/notification.html +++ b/app/templates/views/notifications/notification.html @@ -35,7 +35,7 @@ Estimated delivery date: {{ estimated_letter_delivery_date|string|format_date_short }} {% endif %} diff --git a/app/templates/views/send.html b/app/templates/views/send.html index 0049292f6..0ab3345ca 100644 --- a/app/templates/views/send.html +++ b/app/templates/views/send.html @@ -40,7 +40,7 @@ {% endcall %}- Download this example + Download this example
+ Financial year ends 31 March. +
+
+ What counts as 1 text message?
+ See pricing.
+
Check the Service Manual for guidance on how to: