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//api") @login_required @user_has_permissions('manage_api_keys', admin_override=True) def api_integration(service_id): + callbacks_link = ( + '.api_callbacks' if 'inbound_sms' in current_service['permissions'] + else '.delivery_status_callback' + ) return render_template( 'views/api/index.html', + callbacks_link=callbacks_link, api_notifications=notification_api_client.get_api_notifications_for_service(service_id) ) @@ -113,3 +125,137 @@ def revoke_api_key(service_id, key_id): api_key_api_client.revoke_api_key(service_id=service_id, key_id=key_id) flash('‘{}’ was revoked'.format(key_name), 'default_with_tick') return redirect(url_for('.api_keys', service_id=service_id)) + + +def get_apis(): + callback_api = None + inbound_api = None + if current_service['service_callback_api']: + callback_api = service_api_client.get_service_callback_api( + current_service['id'], + current_service.get('service_callback_api')[0] + ) + if current_service['inbound_api']: + inbound_api = service_api_client.get_service_inbound_api( + current_service['id'], + current_service.get('inbound_api')[0] + ) + + return (callback_api, inbound_api) + + +def check_token_against_dummy_bearer(token): + if token != dummy_bearer_token: + return token + else: + return '' + + +@main.route("/services//api/callbacks", methods=['GET']) +@login_required +def api_callbacks(service_id): + if 'inbound_sms' not in current_service['permissions']: + return redirect(url_for('.delivery_status_callback', service_id=service_id)) + + delivery_status_callback, received_text_messages_callback = get_apis() + + return render_template( + 'views/api/callbacks.html', + received_text_messages_callback=received_text_messages_callback['url'] + if received_text_messages_callback else None, + delivery_status_callback=delivery_status_callback['url'] if delivery_status_callback else None + ) + + +def get_delivery_status_callback_details(): + if current_service['service_callback_api']: + return service_api_client.get_service_callback_api( + current_service['id'], + current_service.get('service_callback_api')[0] + ) + + +@main.route("/services//api/callbacks/delivery-status-callback", methods=['GET', 'POST']) +@login_required +def delivery_status_callback(service_id): + delivery_status_callback = get_delivery_status_callback_details() + back_link = ( + '.api_callbacks' if 'inbound_sms' in current_service['permissions'] + else '.api_integration' + ) + + form = ServiceDeliveryStatusCallbackForm( + url=delivery_status_callback.get('url') if delivery_status_callback else '', + bearer_token=dummy_bearer_token if delivery_status_callback else '' + ) + + if form.validate_on_submit(): + if delivery_status_callback: + if (delivery_status_callback.get('url') != form.url.data + or form.bearer_token.data != dummy_bearer_token): + service_api_client.update_service_callback_api( + service_id, + url=form.url.data, + bearer_token=check_token_against_dummy_bearer(form.bearer_token.data), + user_id=current_user.id, + callback_api_id=delivery_status_callback.get('id') + ) + else: + service_api_client.create_service_callback_api( + service_id, + url=form.url.data, + bearer_token=form.bearer_token.data, + user_id=current_user.id + ) + return redirect(url_for(back_link, service_id=service_id)) + + return render_template( + 'views/api/callbacks/delivery-status-callback.html', + back_link=back_link, + form=form, + ) + + +def get_received_text_messages_callback(): + if current_service['inbound_api']: + return service_api_client.get_service_inbound_api( + current_service['id'], + current_service.get('inbound_api')[0] + ) + + +@main.route("/services//api/callbacks/received-text-messages-callback", methods=['GET', 'POST']) +@login_required +def received_text_messages_callback(service_id): + if 'inbound_sms' not in current_service['permissions']: + return redirect(url_for('.api_integration', service_id=service_id)) + + received_text_messages_callback = get_received_text_messages_callback() + form = ServiceReceiveMessagesCallbackForm( + url=received_text_messages_callback.get('url') if received_text_messages_callback else '', + bearer_token=dummy_bearer_token if received_text_messages_callback else '' + ) + + if form.validate_on_submit(): + if received_text_messages_callback: + if (received_text_messages_callback.get('url') != form.url.data + or form.bearer_token.data != dummy_bearer_token): + service_api_client.update_service_inbound_api( + service_id, + url=form.url.data, + bearer_token=check_token_against_dummy_bearer(form.bearer_token.data), + user_id=current_user.id, + inbound_api_id=received_text_messages_callback.get('id') + ) + else: + service_api_client.create_service_inbound_api( + service_id, + url=form.url.data, + bearer_token=form.bearer_token.data, + user_id=current_user.id + ) + return redirect(url_for('.api_callbacks', service_id=service_id)) + return render_template( + 'views/api/callbacks/received-text-messages-callback.html', + form=form, + ) diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 4c2b05b91..b21bf0fdf 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -135,12 +135,18 @@ def usage(service_id): year, current_financial_year = requested_and_current_financial_year(request) free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year(service_id, year) + units = billing_api_client.get_billable_units(service_id, year) + yearly_usage = billing_api_client.get_service_usage(service_id, year) + + usage_template = 'views/usage.html' + if 'letter' in current_service['permissions']: + usage_template = 'views/usage-with-letters.html' return render_template( - 'views/usage.html', + usage_template, months=list(get_free_paid_breakdown_for_billable_units( year, free_sms_allowance, - billing_api_client.get_billable_units(service_id, year) + units )), selected_year=year, years=get_tuples_of_financial_years( @@ -148,7 +154,7 @@ def usage(service_id): start=current_financial_year - 1, end=current_financial_year + 1, ), - **calculate_usage(billing_api_client.get_service_usage(service_id, year), + **calculate_usage(yearly_usage, free_sms_allowance) ) @@ -335,6 +341,11 @@ def calculate_usage(usage, free_sms_fragment_limit): emails = [breakdown["billing_units"] for breakdown in usage if breakdown['notification_type'] == 'email'] emails_sent = 0 if len(emails) == 0 else emails[0] + letters = [(breakdown["billing_units"], breakdown['letter_total']) for breakdown in usage if + breakdown['notification_type'] == 'letter'] + letter_sent = 0 if len(letters) == 0 else letters[0][0] + letter_cost = 0 if len(letters) == 0 else letters[0][1] + return { 'emails_sent': emails_sent, 'sms_free_allowance': sms_free_allowance, @@ -342,6 +353,8 @@ def calculate_usage(usage, free_sms_fragment_limit): 'sms_allowance_remaining': max(0, (sms_free_allowance - sms_sent)), 'sms_chargeable': max(0, sms_sent - sms_free_allowance), 'sms_rate': sms_rate, + 'letter_sent': letter_sent, + 'letter_cost': letter_cost } @@ -396,18 +409,30 @@ def get_sum_billing_units(billing_units, month=None): def get_free_paid_breakdown_for_billable_units(year, free_sms_fragment_limit, billing_units): cumulative = 0 + letter_cumulative = 0 + sms_units = [x for x in billing_units if x['notification_type'] == 'sms'] + letter_units = [x for x in billing_units if x['notification_type'] == 'letter'] for month in get_months_for_financial_year(year): previous_cumulative = cumulative - monthly_usage = get_sum_billing_units(billing_units, month) + monthly_usage = get_sum_billing_units(sms_units, month) cumulative += monthly_usage breakdown = get_free_paid_breakdown_for_month( free_sms_fragment_limit, cumulative, previous_cumulative, - [billing_month for billing_month in billing_units if billing_month['month'] == month] + [billing_month for billing_month in sms_units if billing_month['month'] == month] ) + letter_billing = [(x['billing_units'], x['rate'], (x['billing_units'] * x['rate'])) + for x in letter_units if x['month'] == month] + letter_total = 0 + for x in letter_billing: + letter_total += x[2] + letter_cumulative += letter_total yield { 'name': month, + 'letter_total': letter_total, + 'letter_cumulative': letter_cumulative, 'paid': breakdown['paid'], - 'free': breakdown['free'] + 'free': breakdown['free'], + 'letters': letter_billing } diff --git a/app/main/views/invites.py b/app/main/views/invites.py index 43e994e06..2da96a154 100644 --- a/app/main/views/invites.py +++ b/app/main/views/invites.py @@ -38,7 +38,7 @@ def accept_invite(token): invited_user = invite_api_client.check_token(token) - if not current_user.is_anonymous and current_user.email_address != invited_user.email_address: + if not current_user.is_anonymous and current_user.email_address.lower() != invited_user.email_address.lower(): message = Markup(""" You’re signed in as {}. This invite is for another email address. diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py index ed6fe756b..d222059e4 100644 --- a/app/main/views/notifications.py +++ b/app/main/views/notifications.py @@ -14,7 +14,7 @@ from app import ( current_service ) from app.main import main -from app.template_previews import TemplatePreview +from app.template_previews import TemplatePreview, get_page_count_for_letter from app.utils import ( user_has_permissions, get_help_argument, @@ -40,6 +40,7 @@ def view_notification(service_id, notification_id): notification_id=notification_id, filetype='png', ), + page_count=get_page_count_for_letter(notification['template']), show_recipient=True, redact_missing_personalisation=True, ) diff --git a/app/main/views/send.py b/app/main/views/send.py index 6e8b81f44..f375c1645 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -2,7 +2,6 @@ import itertools from string import ascii_uppercase from orderedset import OrderedSet -from contextlib import suppress from zipfile import BadZipFile from xlrd.biffh import XLRDError from werkzeug.routing import RequestRedirect @@ -21,7 +20,6 @@ from flask import ( from flask_login import login_required, current_user from notifications_python_client.errors import HTTPError -from notifications_utils.columns import Columns from notifications_utils.recipients import ( RecipientCSV, first_column_headings, @@ -461,7 +459,7 @@ def send_test_preview(service_id, template_id, filetype): return TemplatePreview.from_utils_template(template, filetype, page=request.args.get('page')) -def _check_messages(service_id, template_type, upload_id, letters_as_pdf=False): +def _check_messages(service_id, template_type, upload_id, preview_row, letters_as_pdf=False): if not session.get('upload_data'): # if we just return a `redirect` (302) object here, we'll get errors when we try and unpack in the @@ -497,6 +495,7 @@ def _check_messages(service_id, template_type, upload_id, letters_as_pdf=False): template_type=template_type, upload_id=upload_id, filetype='png', + row_index=preview_row, ) if not letters_as_pdf else None, email_reply_to=email_reply_to, sms_sender=sms_sender @@ -522,23 +521,21 @@ def _check_messages(service_id, template_type, upload_id, letters_as_pdf=False): back_link = url_for('.send_messages', service_id=service_id, template_id=template.id) choose_time_form = ChooseTimeForm() - with suppress(StopIteration): - first_recipient = None - template.values = next(recipients.rows) - first_recipient = template.values.get( - Columns.make_key(recipients.recipient_column_headers[0]), - '' - ) + count_of_recipients = len(list(recipients.rows)) - session['upload_data']['notification_count'] = len(list(recipients.rows)) + if preview_row < count_of_recipients: + template.values = recipients[preview_row] + elif preview_row > 0: + abort(404) + + session['upload_data']['notification_count'] = count_of_recipients session['upload_data']['valid'] = not recipients.has_errors return dict( recipients=recipients, - first_recipient=first_recipient, template=template, errors=recipients.has_errors, row_errors=get_errors_for_csv(recipients, template.template_type), - count_of_recipients=session['upload_data']['notification_count'], + count_of_recipients=count_of_recipients, count_of_displayed_recipients=( len(list(recipients.initial_annotated_rows_with_errors)) if any(recipients.rows_with_errors) and not recipients.missing_column_headers else @@ -561,11 +558,12 @@ def _check_messages(service_id, template_type, upload_id, letters_as_pdf=False): @main.route("/services///check/", methods=['GET']) +@main.route("/services///check//row-", methods=['GET']) @login_required @user_has_permissions('send_texts', 'send_emails', 'send_letters') -def check_messages(service_id, template_type, upload_id): +def check_messages(service_id, template_type, upload_id, row_index=0): - data = _check_messages(service_id, template_type, upload_id) + data = _check_messages(service_id, template_type, upload_id, row_index) if ( data['recipients'].too_many_rows or @@ -588,22 +586,24 @@ def check_messages(service_id, template_type, upload_id): @main.route("/services///check/.", methods=['GET']) +@main.route("/services///check//row-.", methods=['GET']) @login_required @user_has_permissions('send_texts', 'send_emails', 'send_letters') -def check_messages_preview(service_id, template_type, upload_id, filetype): +def check_messages_preview(service_id, template_type, upload_id, filetype, row_index=0): if filetype not in ('pdf', 'png'): abort(404) template = _check_messages( - service_id, template_type, upload_id, letters_as_pdf=True + service_id, template_type, upload_id, row_index, letters_as_pdf=True )['template'] return TemplatePreview.from_utils_template(template, filetype) @main.route("/services///check/", methods=['POST']) +@main.route("/services///check//row-", methods=['POST']) @login_required @user_has_permissions('send_texts', 'send_emails', 'send_letters') -def recheck_messages(service_id, template_type, upload_id): +def recheck_messages(service_id, template_type, upload_id, row_index=0): if not session.get('upload_data'): return redirect(url_for('main.choose_template', service_id=service_id)) diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 80bb9d4e9..7631b0f39 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -1,5 +1,3 @@ -from urllib.parse import urlparse - import requests from flask import ( render_template, @@ -33,7 +31,6 @@ from app.main.forms import ( ServiceLetterContactBlockForm, ServiceBrandingOrg, LetterBranding, - ServiceInboundApiForm, InternationalSMSForm, OrganisationTypeForm, FreeSMSAllowance, @@ -44,17 +41,6 @@ from app import user_api_client, current_service, organisations_client, inbound_ from notifications_utils.formatters import formatted_list -dummy_bearer_token = 'bearer_token_set' - - -def get_inbound_api(): - if current_service['inbound_api']: - return service_api_client.get_service_inbound_api( - current_service['id'], - current_service.get('inbound_api')[0] - ) - - @main.route("/services//service-settings") @login_required @user_has_permissions('manage_settings', admin_override=True) @@ -65,14 +51,6 @@ def service_settings(service_id): else: organisation = None - inbound_api = get_inbound_api() - if inbound_api: - parsed_url = urlparse(inbound_api.get('url')) if inbound_api else '' - inbound_api_url = '{uri.scheme}://{uri.netloc}{elide_token}'.format( - uri=parsed_url, elide_token='...' if parsed_url.path else '') - else: - inbound_api_url = '' - inbound_number = inbound_number_client.get_inbound_sms_number_for_service(service_id) disp_inbound_number = inbound_number['data'].get('number', '') reply_to_email_addresses = service_api_client.get_reply_to_email_addresses(service_id) @@ -100,7 +78,6 @@ def service_settings(service_id): current_service.get('dvla_organisation', '001') ), can_receive_inbound=('inbound_sms' in current_service['permissions']), - inbound_api_url=inbound_api_url, inbound_number=disp_inbound_number, default_reply_to_email_address=default_reply_to_email_address, reply_to_email_address_count=reply_to_email_address_count, @@ -807,41 +784,3 @@ def get_branding_as_dict(organisations): 'colour': organisation['colour'] } for organisation in organisations } - - -@main.route("/services//service-settings/set-inbound-api", methods=['GET', 'POST']) -@login_required -@user_has_permissions('manage_settings', admin_override=True) -def service_set_inbound_api(service_id): - if 'inbound_sms' not in current_service['permissions']: - abort(403) - - inbound_api = get_inbound_api() - form = ServiceInboundApiForm( - url=inbound_api.get('url') if inbound_api else '', - bearer_token=dummy_bearer_token if inbound_api else '' - ) - - if form.validate_on_submit(): - if inbound_api: - if inbound_api.get('url') != form.url.data or form.bearer_token.data != dummy_bearer_token: - service_api_client.update_service_inbound_api( - service_id, - url=form.url.data, - bearer_token=form.bearer_token.data if form.bearer_token.data != dummy_bearer_token else '', - user_id=current_user.id, - inbound_api_id=inbound_api.get('id') - ) - else: - service_api_client.create_service_inbound_api( - service_id, - url=form.url.data, - bearer_token=form.bearer_token.data, - user_id=current_user.id - ) - return redirect(url_for('.service_settings', service_id=service_id)) - - return render_template( - 'views/service-settings/set-inbound-api.html', - form=form, - ) diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index 8721c7451..20033afa0 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -30,6 +30,7 @@ def sign_in(): return redirect(url_for('main.choose_service')) form = LoginForm() + if form.validate_on_submit(): user = user_api_client.get_user_by_email_or_none(form.email_address.data) @@ -39,7 +40,7 @@ def sign_in(): if user and session.get('invited_user'): invited_user = session.get('invited_user') - if user.email_address != invited_user['email_address']: + if user.email_address.lower() != invited_user['email_address'].lower(): flash("You can't accept an invite for another person.") session.pop('invited_user', None) abort(403) diff --git a/app/main/views/templates.py b/app/main/views/templates.py index 86a0aa42e..840483f0f 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -58,7 +58,7 @@ def view_template(service_id, template_id): if template["template_type"] == "letter": letter_contact_details = service_api_client.get_letter_contacts(service_id) default_letter_contact_block_id = next( - (x['id'] for x in letter_contact_details if x['is_default']), "None" + (x['id'] for x in letter_contact_details if x['is_default']), None ) else: default_letter_contact_block_id = None diff --git a/app/notify_client/__init__.py b/app/notify_client/__init__.py index fb0cb7f19..504af8c9f 100644 --- a/app/notify_client/__init__.py +++ b/app/notify_client/__init__.py @@ -1,7 +1,7 @@ from flask_login import current_user from flask import has_request_context, request, abort from notifications_python_client.base import BaseAPIClient -from notifications_python_client.version import __version__ +from notifications_python_client import __version__ def _attach_current_user(data): diff --git a/app/notify_client/invite_api_client.py b/app/notify_client/invite_api_client.py index 352aaef77..a3b2589b7 100644 --- a/app/notify_client/invite_api_client.py +++ b/app/notify_client/invite_api_client.py @@ -9,6 +9,7 @@ class InviteApiClient(NotifyAdminAPIClient): def init_app(self, app): self.base_url = app.config['API_HOST_NAME'] + self.admin_url = app.config['ADMIN_BASE_URL'] self.service_id = app.config['ADMIN_CLIENT_USER_NAME'] self.api_key = app.config['ADMIN_CLIENT_SECRET'] @@ -18,7 +19,8 @@ class InviteApiClient(NotifyAdminAPIClient): 'email_address': email_address, 'from_user': invite_from_id, 'permissions': permissions, - 'auth_type': auth_type + 'auth_type': auth_type, + 'invite_link_host': self.admin_url, } data = _attach_current_user(data) resp = self.post(url='/service/{}/invite'.format(service_id), data=data) diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 08f78c038..2b7b6ead4 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -387,6 +387,30 @@ class ServiceAPIClient(NotifyAdminAPIClient): } ) + def get_service_callback_api(self, service_id, callback_api_id): + return self.get( + "/service/{}/delivery-receipt-api/{}".format( + service_id, callback_api_id + ) + )['data'] + + def update_service_callback_api(self, service_id, url, bearer_token, user_id, callback_api_id): + data = { + "url": url, + "updated_by_id": user_id + } + if bearer_token: + data['bearer_token'] = bearer_token + return self.post("/service/{}/delivery-receipt-api/{}".format(service_id, callback_api_id), data) + + def create_service_callback_api(self, service_id, url, bearer_token, user_id): + data = { + "url": url, + "bearer_token": bearer_token, + "updated_by_id": user_id + } + return self.post("/service/{}/delivery-receipt-api".format(service_id), data) + class ServicesBrowsableItem(BrowsableItem): @property diff --git a/app/templates/components/textbox.html b/app/templates/components/textbox.html index f895dfde6..9d032d1ca 100644 --- a/app/templates/components/textbox.html +++ b/app/templates/components/textbox.html @@ -41,11 +41,12 @@ ' form-control-error' if field.errors else '' ) %} - {{ field(**{ - 'class': field_class, - 'data-module': 'highlight-tags' if highlight_tags else '', - 'rows': rows|string - }) }} + {{ field( + class=field_class, + data_module='highlight-tags' if highlight_tags else '', + rows=rows|string, + **kwargs + ) }} {% if suffix %} {{ suffix }} {% endif %} diff --git a/app/templates/partials/jobs/notifications.html b/app/templates/partials/jobs/notifications.html index 70a086715..5a9c3ffd7 100644 --- a/app/templates/partials/jobs/notifications.html +++ b/app/templates/partials/jobs/notifications.html @@ -31,7 +31,7 @@

{% elif notifications %}

- 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 %} +

Callbacks

+
+ {% call mapping_table( + caption='General', + field_headings=['Label', 'Value', 'Action'], + field_headings_visible=False, + caption_visible=False + ) %} + {% call row() %} + {{ text_field('Callbacks for delivery receipts') }} + {{ optional_text_field(delivery_status_callback) }} + {{ edit_field('Change', url_for('.delivery_status_callback', service_id=current_service.id)) }} + {% endcall %} + + {% call row() %} + {{ text_field('Callbacks for received text messages') }} + {{ optional_text_field(received_text_messages_callback) }} + {{ edit_field('Change', url_for('.received_text_messages_callback', service_id=current_service.id)) }} + {% endcall %} + {% endcall %} +
+{% endblock %} diff --git a/app/templates/views/service-settings/set-inbound-api.html b/app/templates/views/api/callbacks/delivery-status-callback.html similarity index 52% rename from app/templates/views/service-settings/set-inbound-api.html rename to app/templates/views/api/callbacks/delivery-status-callback.html index 9530b36d5..a8c188c75 100644 --- a/app/templates/views/service-settings/set-inbound-api.html +++ b/app/templates/views/api/callbacks/delivery-status-callback.html @@ -3,32 +3,33 @@ {% from "components/page-footer.html" import page_footer %} {% block service_page_title %} - Callback URL + Callbacks for delivery receipts {% endblock %} {% block maincolumn_content %}
-

Callback URL for received text messages

+

Callbacks for delivery receipts

- 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.

{{ textbox( form.url, - width='2-3', - hint='Valid https URL' + width='1-1', + hint='Must start with https://' ) }} {{ textbox( form.bearer_token, - width='2-3', - hint='At least 10 characters' + width='1-1', + hint='At least 10 characters', + autocomplete='new-password' ) }} {{ page_footer( 'Save', - back_link=url_for('.service_settings', service_id=current_service.id), + back_link=url_for(back_link, service_id=current_service.id), back_link_text='Back to settings' ) }}
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 %} +

Callbacks for received text messages

+
+
+

+ When you receive a text message in Notify, we can forward it to your system. + Check the callback documentation for more information. +

+ +
+ {{ textbox( + form.url, + width='1-1', + hint='Must start with https://' + ) }} + {{ textbox( + form.bearer_token, + width='1-1', + hint='At least 10 characters', + autocomplete='new-password' + ) }} + {{ page_footer( + 'Save', + back_link=url_for('.api_callbacks', service_id=current_service.id), + back_link_text='Back to settings' + ) }} +
+
+
+ +{% endblock %} diff --git a/app/templates/views/api/index.html b/app/templates/views/api/index.html index 1b99ff3e1..195f6a62f 100644 --- a/app/templates/views/api/index.html +++ b/app/templates/views/api/index.html @@ -21,7 +21,7 @@ Whitelist
diff --git a/app/templates/views/callbacks.html b/app/templates/views/callbacks.html index 643153911..dceb32a46 100644 --- a/app/templates/views/callbacks.html +++ b/app/templates/views/callbacks.html @@ -2,41 +2,49 @@ {% extends "withoutnav_template.html" %} {% block per_page_title %} - Callbacks for received text messages + Callback documentation {% endblock %} {% block maincolumn_content %}
-

Callbacks for received text messages

-

- Text messages you receive can be forwarded to a URL that you specify, using our callback feature. -

+

Callback documentation

+

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. -

- -

Format of the callback

- -

- The format of the callback message you receive is JSON. -

+

Email and text message delivery receipts

+

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.

+
+ {% call mapping_table( + caption='Callback message format', + field_headings=['Key', 'Description', 'Format'], + field_headings_visible=True, + caption_visible=False + ) %} + {% for key, description, format in [ + ('id', 'Notify’s id for the status receipts', 'UUID'), + ('reference', 'The reference sent by the service', '12345678'), + ('to', 'The email address of the recipient', 'hello@gov.uk'), + ('status', 'The status of the notification', 'delivered | permanent-failure | temporary-failure | technical-failure'), + ('created_at', 'The time the service sent the request', '2017-05-14T12:15:30.000000Z'), + ('completed_at', 'The last time the status was updated', '2017-05-14T12:15:30.000000Z'), + ('sent_at', 'The time the notification was sent', '2017-05-14T12:15:30.000000Z or nil'), + ('notification_type', 'The notification type', 'email | sms | letter') + ] %} + {% call row() %} + {% call row_heading() %} {{ key }} {% endcall %} + {{ text_field(description) }} + {{ text_field(format) }} + {% endcall %} + {% endfor %} + {% endcall %} +
+

Text messages you receive

+

If your service receives text messages in Notify, we can forward them to your callback URL as soon as they arrive.

{% call mapping_table( caption='Callback message format', @@ -59,5 +67,4 @@ {% endfor %} {% endcall %}
- {% endblock %} diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html index 553449600..4fec14c21 100644 --- a/app/templates/views/check/column-errors.html +++ b/app/templates/views/check/column-errors.html @@ -38,12 +38,18 @@

Your file is missing some rows

-

- 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 %}
-
- {% if request.args.from_test %} - Back - {% else %} - {{file_upload(form.file, button_text='Re-upload your file')}} - {% endif %} + +
+
+ {% if request.args.from_test %} + Back + {% else %} + {{file_upload(form.file, button_text='Re-upload your file')}} + {% endif %} +
+ Back to top
{% if not request.args.from_test %}

{{ original_file_name }}

- {% call(item, row_number) list_table( - recipients.initial_annotated_rows_with_errors if row_errors and not recipients.missing_column_headers else recipients.initial_annotated_rows, - caption=original_file_name, - caption_visible=False, - field_headings=[ - 'Row in file'|safe - ] + recipients.column_headers - ) %} - {% call index_field() %} - - {{ item.index + 2 }} - - {% endcall %} - {% for column in recipients.column_headers %} - {% if item['columns'][column].error and not recipients.missing_column_headers %} - {% call field() %} - - {{ item['columns'][column].error }} - {{ item['columns'][column].data if item['columns'][column].data != None }} - - {% endcall %} - {% elif item['columns'][column].ignore %} - {{ text_field(item['columns'][column].data or '', status='default') }} - {% else %} - {{ text_field(item['columns'][column].data or '') }} - {% endif %} - {% endfor %} - {% if item['columns'].get(None) %} - {% for column in item['columns'][None].data %} - {{ text_field(column, status='default') }} +
+ {% call(item, row_number) list_table( + recipients.initial_annotated_rows_with_errors if row_errors and not recipients.missing_column_headers else recipients.initial_annotated_rows, + caption=original_file_name, + caption_visible=False, + field_headings=[ + 'Row in file'|safe + ] + recipients.column_headers + ) %} + {% call index_field() %} + + {{ item.index + 2 }} + + {% endcall %} + {% for column in recipients.column_headers %} + {% if item['columns'][column].error and not recipients.missing_column_headers %} + {% call field() %} + + {{ item['columns'][column].error }} + {{ item['columns'][column].data if item['columns'][column].data != None }} + + {% endcall %} + {% elif item['columns'][column].ignore %} + {{ text_field(item['columns'][column].data or '', status='default') }} + {% else %} + {{ text_field(item['columns'][column].data or '') }} + {% endif %} {% endfor %} - {% endif %} - {% endcall %} - - {% endif %} - + {% if item['columns'].get(None) %} + {% for column in item['columns'][None].data %} + {{ text_field(column, status='default') }} + {% endfor %} + {% endif %} + {% endcall %} + {% endif %} +
{% if recipients.too_many_rows %}

{{ original_file_name }}

- {% call(item, row_number) list_table( - recipients.initial_annotated_rows_with_errors if row_errors and not recipients.missing_column_headers else recipients.initial_annotated_rows, - caption=original_file_name, - caption_visible=False, - field_headings=[ - 'Row in file'|safe - ] + recipients.column_headers - ) %} - {% call index_field() %} - - {{ item.index + 2 }} - - {% endcall %} - {% for column in recipients.column_headers %} - {% if item['columns'][column].ignore %} - {{ text_field(item['columns'][column].data or '', status='default') }} - {% else %} - {{ text_field(item['columns'][column].data or '') }} - {% endif %} - {% endfor %} - {% if item['columns'].get(None) %} - {% for column in item['columns'][None].data %} - {{ text_field(column, status='default') }} +
+ {% call(item, row_number) list_table( + recipients.initial_annotated_rows_with_errors if row_errors and not recipients.missing_column_headers else recipients.initial_annotated_rows, + caption=original_file_name, + caption_visible=False, + field_headings=[ + 'Row in file'|safe + ] + recipients.column_headers + ) %} + {% call index_field() %} + + {{ item.index + 2 }} + + {% endcall %} + {% for column in recipients.column_headers %} + {% if item['columns'][column].ignore %} + {{ text_field(item['columns'][column].data or '', status='default') }} + {% else %} + {{ text_field(item['columns'][column].data or '') }} + {% endif %} {% endfor %} - {% endif %} - {% endcall %} + {% if item['columns'].get(None) %} + {% for column in item['columns'][None].data %} + {{ text_field(column, status='default') }} + {% endfor %} + {% endif %} + {% endcall %} +
{% endif %} {% if count_of_displayed_recipients < count_of_recipients %} {% 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 %} -
+
{% call banner_wrapper(type='dangerous') %} {% if row_errors|length == 1 %}

@@ -45,44 +45,48 @@ {% endcall %}

-
- {{ file_upload(form.file, button_text='Re-upload your file') }} +
+
+ {{ file_upload(form.file, button_text='Re-upload your file') }} +
+ Back to top
- {% call(item, row_number) list_table( - recipients.initial_annotated_rows_with_errors if row_errors and not recipients.missing_column_headers else recipients.initial_annotated_rows, - caption=original_file_name, - caption_visible=False, - field_headings=[ - 'Row in file'|safe - ] + recipients.column_headers - ) %} - {% call index_field() %} - - {{ item.index + 2 }} - - {% endcall %} - {% for column in recipients.column_headers %} - {% if item['columns'][column].error and not recipients.missing_column_headers %} - {% call field() %} - - {{ item['columns'][column].error }} - {{ item['columns'][column].data if item['columns'][column].data != None }} - - {% endcall %} - {% elif item['columns'][column].ignore %} - {{ text_field(item['columns'][column].data or '', status='default') }} - {% else %} - {{ text_field(item['columns'][column].data or '') }} - {% endif %} - {% endfor %} - {% if item['columns'].get(None) %} - {% for column in item['columns'][None].data %} - {{ text_field(column, status='default') }} +
+ {% call(item, row_number) list_table( + recipients.initial_annotated_rows_with_errors if row_errors and not recipients.missing_column_headers else recipients.initial_annotated_rows, + caption=original_file_name, + caption_visible=False, + field_headings=[ + 'Row in file'|safe + ] + recipients.column_headers + ) %} + {% call index_field() %} + + {{ item.index + 2 }} + + {% endcall %} + {% for column in recipients.column_headers %} + {% if item['columns'][column].error and not recipients.missing_column_headers %} + {% call field() %} + + {{ item['columns'][column].error }} + {{ item['columns'][column].data if item['columns'][column].data != None }} + + {% endcall %} + {% elif item['columns'][column].ignore %} + {{ text_field(item['columns'][column].data or '', status='default') }} + {% else %} + {{ text_field(item['columns'][column].data or '') }} + {% endif %} {% endfor %} - {% endif %} - {% endcall %} - + {% if item['columns'].get(None) %} + {% for column in item['columns'][None].data %} + {{ text_field(column, status='default') }} + {% endfor %} + {% endif %} + {% endcall %} +
{% if count_of_displayed_recipients < count_of_recipients %}
{% if messages %}

- 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 }}

- Download as a PDF + Download as a PDF

{% 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 %}

Your file will populate this template ({{ template.name }})

diff --git a/app/templates/views/service-settings.html b/app/templates/views/service-settings.html index c4bbb8d6e..51bf2750c 100644 --- a/app/templates/views/service-settings.html +++ b/app/templates/views/service-settings.html @@ -120,14 +120,6 @@ {{ edit_field('Change', url_for('.service_set_inbound_sms', service_id=current_service.id)) }} {% endcall %} - {% if can_receive_inbound %} - {% call row() %} - {{ text_field('Callback URL for received text messages') }} - {{ optional_text_field(inbound_api_url) }} - {{ edit_field('Change', url_for('.service_set_inbound_api', service_id=current_service.id)) }} - {% endcall %} - {% endif %} - {% endif %} {% endcall %} diff --git a/app/templates/views/service-settings/letter-contact/add.html b/app/templates/views/service-settings/letter-contact/add.html index c1cab5570..dfcecd45a 100644 --- a/app/templates/views/service-settings/letter-contact/add.html +++ b/app/templates/views/service-settings/letter-contact/add.html @@ -29,7 +29,7 @@ {% endif %} {{ page_footer( 'Add', - back_link=url_for('.service_letter_contact_details', service_id=current_service.id), + back_link=None if request.args.get('from_template') else url_for('.service_letter_contact_details', service_id=current_service.id), back_link_text='Back' ) }} diff --git a/app/templates/views/signedout.html b/app/templates/views/signedout.html index 36c2d459c..5c5968a0d 100644 --- a/app/templates/views/signedout.html +++ b/app/templates/views/signedout.html @@ -120,12 +120,12 @@

Services

-
115
+
131
services

Organisations

-
48
+
55
organisations
diff --git a/app/templates/views/templates/_template.html b/app/templates/views/templates/_template.html index e731b3309..1dd6a41ab 100644 --- a/app/templates/views/templates/_template.html +++ b/app/templates/views/templates/_template.html @@ -35,7 +35,11 @@
{% if current_user.has_permissions(permissions=['manage_templates'], admin_override=True) and template.template_type == 'letter' %} Edit - Edit + {% if default_letter_contact_block_id %} + Edit + {% else %} + Edit + {% endif %} {% endif %} {{ template|string }}
diff --git a/app/templates/views/usage-with-letters.html b/app/templates/views/usage-with-letters.html new file mode 100644 index 000000000..7d26976d6 --- /dev/null +++ b/app/templates/views/usage-with-letters.html @@ -0,0 +1,141 @@ +{% from "components/big-number.html" import big_number %} +{% from "components/table.html" import list_table, field, hidden_field_heading, row_heading, text_field %} +{% from "components/pill.html" import pill %} + +{% extends "withnav_template.html" %} + +{% block service_page_title %} + Usage +{% endblock %} + +{% block maincolumn_content %} + +

Usage

+ +
+ {{ pill(years, selected_year, big_number_args={'smallest': True}) }} +
+
+
+
+

Emails

+
+ {{ big_number(emails_sent, 'sent', smaller=True) }} + {{ big_number("Unlimited", 'free allowance', smaller=True) }} +
+
+
+

Text messages

+
+ {{ big_number(sms_sent, 'sent', smaller=True) }} + {{ big_number(sms_free_allowance, 'free allowance', smaller=True) }} + {{ big_number(sms_allowance_remaining, 'free allowance remaining', smaller=True) }} + {% if sms_chargeable %} + {{ big_number( + sms_chargeable, + 'at {:.2f} pence per message'.format(sms_rate * 100), + smaller=True + ) }} + {% endif %} +
+
+
+

Letters

+
+ {{ big_number(letter_sent, 'sent', smaller=True) }} +
+
+
+ +
+
+
+   +
+
+
+
+ {{ big_number( + (sms_chargeable * sms_rate), + 'spent', + currency="£", + smaller=True + ) }} +
+
+
+
+ {{ big_number( + letter_cost, + 'spent', + currency="£", + smaller=True + ) }} +
+
+ +
+ + {% if months %} +
+ {% call(month, row_index) list_table( + months, + caption="Total spend", + caption_visible=False, + empty_message='', + field_headings=[ + 'By month', + hidden_field_heading('Cost'), + ], + field_headings_visible=True + ) %} + {% call row_heading() %} + {{ month.name }} + {% endcall %} + {% call field(align='left') %} + {{ big_number( + (sms_rate * month.paid) + month.letter_total, + currency="£", + smallest=True + ) }} +
    + {% if month.free %} +
  • {{ "{:,}".format(month.free) }} free text messages
  • + {% endif %} + {% if month.paid %} +
  • {{ "{:,}".format(month.paid) }} text messages at + {{- ' {:.2f}p'.format(sms_rate * 100) }}
  • + {% endif %} + {% if not (month.free or month.paid) %} + + {% endif %} +
+
    + {% for letter in month.letters%} + {% if letter[0] %} +
  • {{ "{:,}".format(letter[0])}} letters at {{ '{:.0f}p'.format(letter[1] * 100) }} +
  • + {% endif %} + {% endfor %} +
+ {% endcall %} + {% endcall %} +
+ {% endif %} +
+ +
+
+

+ Financial year ends 31 March. +

+
+
+

+ What counts as 1 text message?
+ See pricing. +

+
+
+ +{% endblock %} diff --git a/app/templates/views/usage.html b/app/templates/views/usage.html index 3b8fe412d..85fbf32c3 100644 --- a/app/templates/views/usage.html +++ b/app/templates/views/usage.html @@ -113,4 +113,4 @@
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/views/using-notify.html b/app/templates/views/using-notify.html index b1e817f90..d23f89520 100644 --- a/app/templates/views/using-notify.html +++ b/app/templates/views/using-notify.html @@ -19,7 +19,7 @@

Check the Service Manual for guidance on how to:

  • plan and write text messages and emails
  • -
  • send emails in a way that protects users from spam and phishing
  • +
  • send emails in a way that protects users from spam and phishing

Trial mode

diff --git a/appspec.yml b/appspec.yml deleted file mode 100644 index 0a55dcfa1..000000000 --- a/appspec.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -files: - - - destination: /home/notify-app/notifications-admin - source: / -hooks: - BeforeInstall: - - location: scripts/aws_clear_instance.sh - runas: root - timeout: 1000 - AfterInstall: - - - location: scripts/aws_install_dependencies.sh - runas: root - timeout: 300 - - - location: scripts/aws_change_ownership.sh - runas: root - timeout: 300 - ApplicationStart: - - - location: scripts/aws_start_app.sh - runas: root - timeout: 300 - - - location: scripts/register_with_elb.sh - runas: ubuntu - timeout: 300 - ApplicationStop: - - - location: scripts/deregister_from_elb.sh - runas: ubuntu - timeout: 300 - - - location: scripts/aws_stop_app.sh - runas: root - timeout: 300 -os: linux -version: 0.0 diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 872630dfb..2f724efd9 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -68,6 +68,7 @@ gulp.task('javascripts', () => gulp paths.src + 'javascripts/liveSearch.js', paths.src + 'javascripts/errorTracking.js', paths.src + 'javascripts/preventDuplicateFormSubmissions.js', + paths.src + 'javascripts/fullscreenTable.js', paths.src + 'javascripts/main.js' ]) .pipe(plugins.prettyerror()) diff --git a/paas-failwhale/README.md b/paas-failwhale/README.md index 25e10f6f4..95e0ea107 100644 --- a/paas-failwhale/README.md +++ b/paas-failwhale/README.md @@ -10,21 +10,17 @@ It should already be deployed, but if not you can deploy it by running cf push notify-admin-failwhale -To direct traffic to it you need to update the routes by running +To enable it you need to run - cf map-route notify-admin-failwhale [ENVIRONMENT-URL] --hostname www - cf unmap-route notify-admin [ENVIRONMENT-URL] --hostname www + make enable-failwhale -To remove admin failwhale: +and to disable it - cf map-route notify-admin [ENVIRONMENT-URL] --hostname www - cf unmap-route notify-admin-failwhale [ENVIRONMENT-URL] --hostname www + make disable-failwhale -Where [ENVIRONMENT-URL] would be one of: +Where `` is any of -- notify.works for preview -- notify-staging.works for staging -- notifications.service.gov.uk for production - -**Make sure you are on the correct environment!** +- preview +- staging +- production diff --git a/requirements.txt b/requirements.txt index 1005181b1..80dff8cdd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,22 +3,21 @@ Flask==0.12.2 Flask-WTF==0.14.2 Flask-Login==0.4.1 -boto3==1.4.8 +boto3==1.5.6 blinker==1.4 pyexcel==0.5.6 -pyexcel-io==0.5.4 -pyexcel-xls==0.5.4 -pyexcel-xlsx==0.5.4 +pyexcel-io==0.5.5 +pyexcel-xls==0.5.5 +pyexcel-xlsx==0.5.5 pyexcel-ods3==0.5.2 pytz==2017.3 gunicorn==19.7.1 whitenoise==3.3.1 #manages static assets -# pin to minor version 3.1.x -notifications-python-client==4.6.0 +notifications-python-client==4.7.1 # PaaS -awscli==1.14.2 +awscli==1.14.16 awscli-cwlogs>=1.4,<1.5 -git+https://github.com/alphagov/notifications-utils.git@23.1.0#egg=notifications-utils==23.1.0 +git+https://github.com/alphagov/notifications-utils.git@23.4.0#egg=notifications-utils==23.4.0 diff --git a/requirements_for_test.txt b/requirements_for_test.txt index b1cc3a025..7e0d0651e 100644 --- a/requirements_for_test.txt +++ b/requirements_for_test.txt @@ -1,10 +1,11 @@ -r requirements.txt -pytest==3.3.0 +pytest==3.3.2 pytest-mock==1.6.3 pytest-cov==2.5.1 -pytest-xdist==1.20.1 +pytest-xdist==1.21.0 coveralls==1.2.0 httpretty==0.8.14 beautifulsoup4==4.6.0 freezegun==0.3.9 flake8==3.5.0 +flake8-print==3.0.1 diff --git a/scripts/aws_change_ownership.sh b/scripts/aws_change_ownership.sh deleted file mode 100755 index e800956d3..000000000 --- a/scripts/aws_change_ownership.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -echo "Chown application to be owned by ubuntu" -cd /home/notify-app/; -chown -R notify-app:govuk-notify-applications notifications-admin \ No newline at end of file diff --git a/scripts/aws_clear_instance.sh b/scripts/aws_clear_instance.sh deleted file mode 100755 index 1b159ddb3..000000000 --- a/scripts/aws_clear_instance.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -echo "Removing application and dependencies" - -if [ -d "/home/notify-app/notifications-admin" ]; then - # Remove and re-create the directory - rm -rf /home/notify-app/notifications-admin - mkdir -p /home/notify-app/notifications-admin -fi - diff --git a/scripts/aws_install_dependencies.sh b/scripts/aws_install_dependencies.sh deleted file mode 100755 index ccf5a6578..000000000 --- a/scripts/aws_install_dependencies.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -echo "Install dependencies" -cd /home/notify-app/notifications-admin; -pip3 install --find-links=vendor -r /home/notify-app/notifications-admin/requirements.txt diff --git a/scripts/aws_start_app.sh b/scripts/aws_start_app.sh deleted file mode 100755 index b4082672a..000000000 --- a/scripts/aws_start_app.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -echo "Starting application" -sudo service notifications-admin start \ No newline at end of file diff --git a/scripts/aws_stop_app.sh b/scripts/aws_stop_app.sh deleted file mode 100755 index 2b90414a1..000000000 --- a/scripts/aws_stop_app.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - - -function error_exit -{ - echo "$1" 1>&2 - exit 0 -} - -echo "Stopping application" -if sudo service notifications-admin stop; then - exit 0 -else - error_exit "Could not stop application" -fi \ No newline at end of file diff --git a/scripts/common_functions.sh b/scripts/common_functions.sh deleted file mode 100644 index d9fa96b3b..000000000 --- a/scripts/common_functions.sh +++ /dev/null @@ -1,384 +0,0 @@ -#!/bin/bash -# -# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# A copy of the License is located at -# -# http://aws.amazon.com/apache2.0 -# -# or in the "license" file accompanying this file. This file is distributed -# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either -# express or implied. See the License for the specific language governing -# permissions and limitations under the License. - -# ELB_LIST defines which Elastic Load Balancers this instance should be part of. -# The elements in ELB_LIST should be seperated by space. -ELB_LIST="" - -# Under normal circumstances, you shouldn't need to change anything below this line. -# ----------------------------------------------------------------------------- - -export PATH="$PATH:/usr/bin:/usr/local/bin" - -# If true, all messages will be printed. If false, only fatal errors are printed. -DEBUG=true - -# Number of times to check for a resouce to be in the desired state. -WAITER_ATTEMPTS=60 - -# Number of seconds to wait between attempts for resource to be in a state. -WAITER_INTERVAL=1 - -# AutoScaling Standby features at minimum require this version to work. -MIN_CLI_VERSION='1.3.25' - -# Usage: get_instance_region -# -# Writes to STDOUT the AWS region as known by the local instance. -get_instance_region() { - if [ -z "$AWS_REGION" ]; then - AWS_REGION=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \ - | grep -i region \ - | awk -F\" '{print $4}') - fi - - echo $AWS_REGION -} - -AWS_CLI="aws --region $(get_instance_region)" - - -# Usage: get_instance_state_asg -# -# Gets the state of the given as known by the AutoScaling group it's a part of. -# Health is printed to STDOUT and the function returns 0. Otherwise, no output and return is -# non-zero. -get_instance_state_asg() { - local instance_id=$1 - - local state=$($AWS_CLI autoscaling describe-auto-scaling-instances \ - --instance-ids $instance_id \ - --query "AutoScalingInstances[?InstanceId == \`$instance_id\`].LifecycleState | [0]" \ - --output text) - if [ $? != 0 ]; then - return 1 - else - echo $state - return 0 - fi -} - -reset_waiter_timeout() { - local elb=$1 - local state_name=$2 - - if [ "$state_name" == "InService" ]; then - - # Wait for a health check to succeed - local timeout=$($AWS_CLI elb describe-load-balancers \ - --load-balancer-name $elb \ - --query 'LoadBalancerDescriptions[0].HealthCheck.Timeout') - - elif [ "$state_name" == "OutOfService" ]; then - - # If connection draining is enabled, wait for connections to drain - local draining_values=$($AWS_CLI elb describe-load-balancer-attributes \ - --load-balancer-name $elb \ - --query 'LoadBalancerAttributes.ConnectionDraining.[Enabled,Timeout]' \ - --output text) - local draining_enabled=$(echo $draining_values | awk '{print $1}') - local timeout=$(echo $draining_values | awk '{print $2}') - - if [ "$draining_enabled" != "True" ]; then - timeout=0 - fi - - else - msg "Unknown state name, '$state_name'"; - return 1; - fi - - # Base register/deregister action may take up to about 30 seconds - timeout=$((timeout + 60)) - - WAITER_ATTEMPTS=$((timeout / WAITER_INTERVAL)) -} - -# Usage: wait_for_state [ELB name] -# -# Waits for the state of to be in as seen by . Returns 0 if -# it successfully made it to that state; non-zero if not. By default, checks $WAITER_ATTEMPTS -# times, every $WAITER_INTERVAL seconds. If giving an [ELB name] to check under, these are reset -# to that ELB's timeout values. -wait_for_state() { - local service=$1 - local instance_id=$2 - local state_name=$3 - local elb=$4 - - local instance_state_cmd - if [ "$service" == "elb" ]; then - instance_state_cmd="get_instance_health_elb $instance_id $elb" - reset_waiter_timeout $elb $state_name - if [ $? != 0 ]; then - error_exit "Failed resetting waiter timeout for $elb" - fi - elif [ "$service" == "autoscaling" ]; then - instance_state_cmd="get_instance_state_asg $instance_id" - else - msg "Cannot wait for instance state; unknown service type, '$service'" - return 1 - fi - - msg "Checking $WAITER_ATTEMPTS times, every $WAITER_INTERVAL seconds, for instance $instance_id to be in state $state_name" - - local instance_state=$($instance_state_cmd) - local count=1 - - msg "Instance is currently in state: $instance_state" - while [ "$instance_state" != "$state_name" ]; do - if [ $count -ge $WAITER_ATTEMPTS ]; then - local timeout=$(($WAITER_ATTEMPTS * $WAITER_INTERVAL)) - msg "Instance failed to reach state, $state_name within $timeout seconds" - return 1 - fi - - sleep $WAITER_INTERVAL - - instance_state=$($instance_state_cmd) - count=$(($count + 1)) - msg "Instance is currently in state: $instance_state" - done - - return 0 -} - -# Usage: get_instance_health_elb -# -# Gets the health of the given as known by . If it's a valid health -# status (one of InService|OutOfService|Unknown), then the health is printed to STDOUT and the -# function returns 0. Otherwise, no output and return is non-zero. -get_instance_health_elb() { - local instance_id=$1 - local elb_name=$2 - - msg "Checking status of instance '$instance_id' in load balancer '$elb_name'" - - # If describe-instance-health for this instance returns an error, then it's not part of - # this ELB. But, if the call was successful let's still double check that the status is - # valid. - local instance_status=$($AWS_CLI elb describe-instance-health \ - --load-balancer-name $elb_name \ - --instances $instance_id \ - --query 'InstanceStates[].State' \ - --output text 2>/dev/null) - - if [ $? == 0 ]; then - case "$instance_status" in - InService|OutOfService|Unknown) - echo -n $instance_status - return 0 - ;; - *) - msg "Instance '$instance_id' not part of ELB '$elb_name'" - return 1 - esac - fi -} - -# Usage: validate_elb -# -# Validates that the Elastic Load Balancer with name exists, is describable, and -# contains as one of its instances. -# -# If any of these checks are false, the function returns non-zero. -validate_elb() { - local instance_id=$1 - local elb_name=$2 - - # Get the list of active instances for this LB. - local elb_instances=$($AWS_CLI elb describe-load-balancers \ - --load-balancer-name $elb_name \ - --query 'LoadBalancerDescriptions[*].Instances[*].InstanceId' \ - --output text) - if [ $? != 0 ]; then - msg "Couldn't describe ELB instance named '$elb_name'" - return 1 - fi - - msg "Checking health of '$instance_id' as known by ELB '$elb_name'" - local instance_health=$(get_instance_health_elb $instance_id $elb_name) - if [ $? != 0 ]; then - return 1 - fi - - return 0 -} - -# Usage: get_elb_list -# -# Ensures that this instance is related to the named ELB. After execution, the variable -# "ELB_LIST" will contain the list of load balancers for the given instance. -# -# If the given instance ID isn't found registered to any ELBs, the function returns non-zero -get_elb_list() { - local instance_id=$1 - local required_elb=$2 - local elb_list="" - - msg "Looking up from ELB list" - local all_balancers=$($AWS_CLI elb describe-load-balancers \ - --query LoadBalancerDescriptions[*].LoadBalancerName \ - --output text | sed -e $'s/\t/ /g') - - if [[ $all_balancers =~ $required_elb ]] - then - local instance_health - instance_health=$(get_instance_health_elb $instance_id $required_elb) - if [ $? == 0 ]; then - elb_list="$elb_list $required_elb" - fi - fi - - if [ -z "$elb_list" ]; then - return 1 - else - msg "Got load balancer list of: $elb_list" - ELB_LIST=$elb_list - return 0 - fi -} - -# Usage: deregister_instance -# -# Deregisters from . -deregister_instance() { - local instance_id=$1 - local elb_name=$2 - - $AWS_CLI elb deregister-instances-from-load-balancer \ - --load-balancer-name $elb_name \ - --instances $instance_id 1> /dev/null - - return $? -} - -# Usage: register_instance -# -# Registers to . -register_instance() { - local instance_id=$1 - local elb_name=$2 - - $AWS_CLI elb register-instances-with-load-balancer \ - --load-balancer-name $elb_name \ - --instances $instance_id 1> /dev/null - - return $? -} - -# Usage: check_cli_version [version-to-check] [desired version] -# -# Without any arguments, checks that the installed version of the AWS CLI is at least at version -# $MIN_CLI_VERSION. Returns non-zero if the version is not high enough. -check_cli_version() { - if [ -z $1 ]; then - version=$($AWS_CLI --version 2>&1 | cut -f1 -d' ' | cut -f2 -d/) - else - version=$1 - fi - - if [ -z "$2" ]; then - min_version=$MIN_CLI_VERSION - else - min_version=$2 - fi - - x=$(echo $version | cut -f1 -d.) - y=$(echo $version | cut -f2 -d.) - z=$(echo $version | cut -f3 -d.) - - min_x=$(echo $min_version | cut -f1 -d.) - min_y=$(echo $min_version | cut -f2 -d.) - min_z=$(echo $min_version | cut -f3 -d.) - - msg "Checking minimum required CLI version (${min_version}) against installed version ($version)" - - if [ $x -lt $min_x ]; then - return 1 - elif [ $y -lt $min_y ]; then - return 1 - elif [ $y -gt $min_y ]; then - return 0 - elif [ $z -ge $min_z ]; then - return 0 - else - return 1 - fi -} - -# Usage: msg -# -# Writes to STDERR only if $DEBUG is true, otherwise has no effect. -msg() { - local message=$1 - $DEBUG && echo $message 1>&2 -} - -# Usage: error_exit -# -# Writes to STDERR as a "fatal" and immediately exits the currently running script. -error_exit() { - local message=$1 - - echo "[FATAL] $message" 1>&2 - exit 1 -} - -# Usage: get_instance_id -# -# Writes to STDOUT the EC2 instance ID for the local instance. Returns non-zero if the local -# instance metadata URL is inaccessible. -get_instance_id() { - curl -s http://169.254.169.254/latest/meta-data/instance-id - return $? -} - -# Usage: get_instance_name_from_tags -# -# Looks up tags for the given instance, extracting the 'name' -# returns or error_exit -get_instance_name_from_tags() { - local instance_id=$1 - - local instance_name=$($AWS_CLI ec2 describe-tags \ - --filters "Name=resource-id,Values=${instance_id}" \ - --query Tags[0].Value \ - --output text) - if [ $? != 0 ]; then - error_exit "Couldn't get instance name for '$instance_id'" - fi - echo $instance_name - return $? -} - -ELB_NAME="" - -get_elb_name_for_instance_name() { - local instance_name=$1 - - declare -A elb_to_instance_mapping - - elb_to_instance_mapping['notify-admin']='notify-admin' - - elb_to_instance_mapping['notify-admin-ags']='notify-admin-ags' - - local elb_name=${elb_to_instance_mapping[${instance_name}]} - if [ -z $elb_name ]; then - msg "No ELB for instance ${instance_name}" - else - ELB_NAME=$elb_name - fi -} diff --git a/scripts/deregister_from_elb.sh b/scripts/deregister_from_elb.sh deleted file mode 100755 index 62bab1612..000000000 --- a/scripts/deregister_from_elb.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/bin/bash -# -# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# A copy of the License is located at -# -# http://aws.amazon.com/apache2.0 -# -# or in the "license" file accompanying this file. This file is distributed -# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either -# express or implied. See the License for the specific language governing -# permissions and limitations under the License. - -. $(dirname $0)/common_functions.sh - -msg "Running AWS CLI with region: $(get_instance_region)" - -# get this instance's ID -INSTANCE_ID=$(get_instance_id) -if [ $? != 0 -o -z "$INSTANCE_ID" ]; then - error_exit "Unable to get this instance's ID; cannot continue." -fi - -# Get current time -msg "Started $(basename $0) at $(/bin/date "+%F %T")" -start_sec=$(/bin/date +%s.%N) - -msg "Getting relevant load balancer" -INSTANCE_NAME=$(get_instance_name_from_tags $INSTANCE_ID) -get_elb_name_for_instance_name $INSTANCE_NAME -get_elb_list $INSTANCE_ID $ELB_NAME - -msg "Checking that user set at least one load balancer" -if test -z "$ELB_LIST"; then - error_exit "Must have at least one load balancer to deregister from" -fi - -# Loop through all LBs the user set, and attempt to deregister this instance from them. -for elb in $ELB_LIST; do - msg "Checking validity of load balancer named '$elb'" - validate_elb $INSTANCE_ID $elb - if [ $? != 0 ]; then - msg "Error validating $elb; cannot continue with this LB" - continue - fi - - msg "Deregistering $INSTANCE_ID from $elb" - deregister_instance $INSTANCE_ID $elb - - if [ $? != 0 ]; then - error_exit "Failed to deregister instance $INSTANCE_ID from ELB $elb" - fi -done - -# Wait for all Deregistrations to finish -msg "Waiting for instance to de-register from its load balancers" -for elb in $ELB_LIST; do - wait_for_state "elb" $INSTANCE_ID "OutOfService" $elb - if [ $? != 0 ]; then - error_exit "Failed waiting for $INSTANCE_ID to leave $elb" - fi -done - -msg "Finished $(basename $0) at $(/bin/date "+%F %T")" - -end_sec=$(/bin/date +%s.%N) -elapsed_seconds=$(echo "$end_sec - $start_sec" | /usr/bin/bc) - -msg "Elapsed time: $elapsed_seconds" diff --git a/scripts/register_with_elb.sh b/scripts/register_with_elb.sh deleted file mode 100755 index 7cc78006c..000000000 --- a/scripts/register_with_elb.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash -# -# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# A copy of the License is located at -# -# http://aws.amazon.com/apache2.0 -# -# or in the "license" file accompanying this file. This file is distributed -# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either -# express or implied. See the License for the specific language governing -# permissions and limitations under the License. - -. $(dirname $0)/common_functions.sh - -msg "Running AWS CLI with region: $(get_instance_region)" - -# get this instance's ID -INSTANCE_ID=$(get_instance_id) -if [ $? != 0 -o -z "$INSTANCE_ID" ]; then - error_exit "Unable to get this instance's ID; cannot continue." -fi - -# Get current time -msg "Started $(basename $0) at $(/bin/date "+%F %T")" -start_sec=$(/bin/date +%s.%N) - -msg "Getting relevant load balancer" -INSTANCE_NAME=$(get_instance_name_from_tags $INSTANCE_ID) -get_elb_name_for_instance_name $INSTANCE_NAME -ELB_LIST=$ELB_NAME -get_elb_list $INSTANCE_ID $ELB_NAME - -msg "Checking that user set at least one load balancer" -if test -z "$ELB_LIST"; then - error_exit "Must have at least one load balancer to register to" -fi - -# Loop through all LBs the user set, and attempt to register this instance to them. -for elb in $ELB_LIST; do - msg "Checking validity of load balancer named '$elb'" - validate_elb $INSTANCE_ID $elb - if [ $? != 0 ]; then - msg "Error validating $elb; cannot continue with this LB" - continue - fi - - msg "Registering $INSTANCE_ID to $elb" - register_instance $INSTANCE_ID $elb - - if [ $? != 0 ]; then - error_exit "Failed to register instance $INSTANCE_ID from ELB $elb" - fi -done - -# Wait for all Registrations to finish -msg "Waiting for instance to register to its load balancers" -for elb in $ELB_LIST; do - wait_for_state "elb" $INSTANCE_ID "InService" $elb - if [ $? != 0 ]; then - error_exit "Failed waiting for $INSTANCE_ID to return to $elb" - fi -done - -msg "Finished $(basename $0) at $(/bin/date "+%F %T")" - -end_sec=$(/bin/date +%s.%N) -elapsed_seconds=$(echo "$end_sec - $start_sec" | /usr/bin/bc) - -msg "Elapsed time: $elapsed_seconds" diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index d8d6b5a39..2b5da0565 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -28,7 +28,7 @@ if [[ -z "$VIRTUAL_ENV" ]] && [[ -d venv ]]; then source ./venv/bin/activate fi -flake8 . +flake8 --enable=T . display_result $? 1 "Code style check" npm test diff --git a/tests/__init__.py b/tests/__init__.py index 372d34f6c..b5ba0350d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -53,6 +53,7 @@ def service_json( created_at=None, letter_contact_block=None, inbound_api=None, + service_callback_api=None, permissions=None, organisation_type='central', free_sms_fragment_limit=250000, @@ -84,6 +85,7 @@ def service_json( 'dvla_organisation': '001', 'permissions': permissions, 'inbound_api': inbound_api, + 'service_callback_api': service_callback_api, 'prefix_sms': prefix_sms, } @@ -366,7 +368,6 @@ def validate_route_permission(mocker, else: pytest.fail("Invalid method call {}".format(method)) if resp.status_code != response_code: - print(resp.status_code) pytest.fail("Invalid permissions set for endpoint {}".format(route)) return resp @@ -401,6 +402,5 @@ def validate_route_permission_with_client(mocker, else: pytest.fail("Invalid method call {}".format(method)) if resp.status_code != response_code: - print(resp.status_code) pytest.fail("Invalid permissions set for endpoint {}".format(route)) return resp diff --git a/tests/app/main/test_validators.py b/tests/app/main/test_validators.py index 7c494312b..f7a6f959d 100644 --- a/tests/app/main/test_validators.py +++ b/tests/app/main/test_validators.py @@ -99,6 +99,8 @@ def _gen_mock_field(x): 'test@bbsrc.ac.uk', 'test@acas.org.uk', 'test@biglotteryfund.org.uk', + 'test@marinemanagement.org.uk', + 'test@britishmuseum.org', ]) def test_valid_list_of_white_list_email_domains( client, @@ -149,7 +151,7 @@ def test_for_commas_in_placeholders( ): with pytest.raises(ValidationError) as error: NoCommasInPlaceHolders()(None, _gen_mock_field('Hello ((name,date))')) - assert str(error.value) == 'You can’t have commas in your fields' + assert str(error.value) == 'You can’t put commas between double brackets' NoCommasInPlaceHolders()(None, _gen_mock_field('Hello ((name))')) diff --git a/tests/app/main/views/test_accept_invite.py b/tests/app/main/views/test_accept_invite.py index ae94139c8..0ff67f30b 100644 --- a/tests/app/main/views/test_accept_invite.py +++ b/tests/app/main/views/test_accept_invite.py @@ -315,6 +315,29 @@ def test_signed_in_existing_user_cannot_use_anothers_invite( assert mock_accept_invite.call_count == 0 +def test_accept_invite_does_not_treat_email_addresses_as_case_sensitive( + logged_in_client, + mocker, + api_user_active, + sample_invite, + service_one, + mock_accept_invite, + mock_get_user_by_email +): + mocker.patch('app.main.views.invites.check_token') + + # the email address of api_user_active is 'test@user.gov.uk' + sample_invite['email_address'] = 'TEST@user.gov.uk' + invite = InvitedUser(**sample_invite) + mocker.patch('app.invite_api_client.check_token', return_value=invite) + mocker.patch('app.user_api_client.get_users_for_service', return_value=[api_user_active]) + + response = logged_in_client.get(url_for('main.accept_invite', token='thisisnotarealtoken')) + + assert response.status_code == 302 + assert response.location == url_for('main.service_dashboard', service_id=service_one['id'], _external=True) + + def test_new_invited_user_verifies_and_added_to_service( client, service_one, diff --git a/tests/app/main/views/test_api_keys.py b/tests/app/main/views/test_api_keys.py index 412e9cc69..a874eacad 100644 --- a/tests/app/main/views/test_api_keys.py +++ b/tests/app/main/views/test_api_keys.py @@ -8,11 +8,14 @@ from unittest.mock import call from tests import validate_route_permission from tests.conftest import ( + fake_uuid, mock_get_service, mock_get_live_service, mock_get_service_with_letters, normalize_spaces, SERVICE_ONE_ID, + mock_get_valid_service_callback_api, + mock_get_valid_service_inbound_api, ) @@ -348,8 +351,8 @@ def test_should_update_whitelist( service_id = str(uuid.uuid4()) data = OrderedDict([ ('email_addresses-1', 'test@example.com'), - ('email_addresses-3', 'test@example.com'), - ('phone_numbers-0', '07900900000'), + ('email_addresses-3', ' test@example.com '), + ('phone_numbers-0', '07900900000 '), ('phone_numbers-2', '+1800-555-555'), ]) @@ -391,3 +394,298 @@ def test_should_validate_whitelist_items( assert jump_links[1]['href'] == '#phone_numbers' mock_update_whitelist.assert_not_called() + + +@pytest.mark.parametrize('endpoint', [ + ('main.delivery_status_callback'), + ('main.received_text_messages_callback'), +]) +@pytest.mark.parametrize('url, bearer_token, expected_errors', [ + ("", "", "Can’t be empty Can’t be empty"), + ("http://not_https.com", "1234567890", "Must be a valid https URL"), + ("https://test.com", "123456789", "Must be at least 10 characters"), +]) +def test_callback_forms_validation( + client_request, + service_one, + endpoint, + url, + bearer_token, + expected_errors +): + if endpoint == 'main.received_text_messages_callback': + service_one['permissions'] = ['inbound_sms'] + + data = { + "url": url, + "bearer_token": bearer_token, + } + + response = client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + _expected_status=200 + ) + error_msgs = ' '.join(msg.text.strip() for msg in response.select(".error-message")) + + assert error_msgs == expected_errors + + +@pytest.mark.parametrize('has_inbound_sms, expected_link', [ + (True, 'main.api_callbacks'), + (False, 'main.delivery_status_callback'), +]) +def test_callbacks_button_links_straight_to_delivery_status_if_service_has_no_inbound_sms( + client_request, + service_one, + mocker, + mock_get_notifications, + has_inbound_sms, + expected_link +): + if has_inbound_sms: + service_one['permissions'] = ['inbound_sms'] + + page = client_request.get( + 'main.api_integration', + service_id=service_one['id'], + ) + + assert page.select('.pill-separate-item')[2]['href'] == url_for( + expected_link, service_id=service_one['id'] + ) + + +def test_callbacks_page_redirects_to_delivery_status_if_service_has_no_inbound_sms( + client_request, + service_one, + mocker +): + page = client_request.get( + 'main.api_callbacks', + service_id=service_one['id'], + _follow_redirects=True, + ) + + assert normalize_spaces(page.select_one('h1').text) == "Callbacks for delivery receipts" + + +@pytest.mark.parametrize('has_inbound_sms, expected_link', [ + (True, 'main.api_callbacks'), + (False, 'main.api_integration'), +]) +def test_back_link_directs_to_api_integration_from_delivery_callback_if_no_inbound_sms( + client_request, + service_one, + mocker, + has_inbound_sms, + expected_link +): + if has_inbound_sms: + service_one['permissions'] = ['inbound_sms'] + + page = client_request.get( + 'main.delivery_status_callback', + service_id=service_one['id'], + _follow_redirects=True, + ) + + assert page.select_one('.page-footer-back-link')['href'] == url_for( + expected_link, service_id=service_one['id'] + ) + + +@pytest.mark.parametrize('endpoint', [ + ('main.delivery_status_callback'), + ('main.received_text_messages_callback'), +]) +def test_create_delivery_status_and_receive_text_message_callbacks( + client_request, + service_one, + mocker, + mock_get_notifications, + mock_create_service_inbound_api, + mock_create_service_callback_api, + endpoint, + fake_uuid, +): + if endpoint == 'main.received_text_messages_callback': + service_one['permissions'] = ['inbound_sms'] + + data = { + 'url': "https://test.url.com/", + 'bearer_token': '1234567890', + 'user_id': fake_uuid + } + + client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + ) + + if endpoint == 'main.received_text_messages_callback': + mock_create_service_inbound_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + ) + else: + mock_create_service_callback_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + ) + + +@pytest.mark.parametrize('endpoint, fixture', [ + ('main.delivery_status_callback', mock_get_valid_service_callback_api), + ('main.received_text_messages_callback', mock_get_valid_service_inbound_api), +]) +def test_update_delivery_status_and_receive_text_message_callbacks( + client_request, + service_one, + mocker, + mock_get_notifications, + mock_update_service_inbound_api, + mock_update_service_callback_api, + endpoint, + fixture, + fake_uuid, +): + if endpoint == 'main.received_text_messages_callback': + service_one['inbound_api'] = [fake_uuid] + service_one['permissions'] = ['inbound_sms'] + else: + service_one['service_callback_api'] = [fake_uuid] + + fixture(mocker) + + data = { + 'url': "https://test.url.com/", + 'bearer_token': '1234567890', + 'user_id': fake_uuid + } + + client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + ) + + if endpoint == 'main.received_text_messages_callback': + mock_update_service_inbound_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + inbound_api_id=fake_uuid, + ) + else: + mock_update_service_callback_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + callback_api_id=fake_uuid + ) + + +@pytest.mark.parametrize('endpoint, data, fixture', [ + ( + 'main.delivery_status_callback', + {"url": "https://hello2.gov.uk", "bearer_token": "bearer_token_set"}, + mock_get_valid_service_callback_api + ), + ( + 'main.received_text_messages_callback', + {"url": "https://hello3.gov.uk", "bearer_token": "bearer_token_set"}, + mock_get_valid_service_inbound_api + ), +]) +def test_update_delivery_status_and_receive_text_message_callbacks_without_changes_do_not_update( + client_request, + service_one, + mocker, + mock_get_notifications, + mock_update_service_callback_api, + mock_update_service_inbound_api, + data, + fixture, + endpoint, + fake_uuid, +): + if endpoint == 'main.received_text_messages_callback': + service_one['inbound_api'] = [fake_uuid] + service_one['permissions'] = ['inbound_sms'] + else: + service_one['service_callback_api'] = [fake_uuid] + + fixture(mocker) + + data['user_id'] = fake_uuid + + client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + ) + + if endpoint == 'main.received_text_messages_callback': + assert mock_update_service_inbound_api.called is False + else: + assert mock_update_service_callback_api.called is False + + +@pytest.mark.parametrize('service_callback_api, delivery_url, expected_1st_table_row', [ + ( + None, {}, + 'Callbacks for delivery receipts Not set Change' + ), + ( + fake_uuid(), {'url': 'https://delivery.receipts'}, + 'Callbacks for delivery receipts https://delivery.receipts Change' + ), +]) +@pytest.mark.parametrize('inbound_api, inbound_url, expected_2nd_table_row', [ + ( + None, {}, + 'Callbacks for received text messages Not set Change' + ), + ( + fake_uuid(), {'url': 'https://inbound.sms'}, + 'Callbacks for received text messages https://inbound.sms Change' + ), +]) +def test_callbacks_page_works_when_no_apis_set( + client_request, + service_one, + mocker, + service_callback_api, + delivery_url, + expected_1st_table_row, + inbound_api, + inbound_url, + expected_2nd_table_row, +): + service_one['permissions'] = ['inbound_sms'] + service_one['inbound_api'] = inbound_api + service_one['service_callback_api'] = service_callback_api + + mocker.patch('app.service_api_client.get_service_callback_api', return_value=delivery_url) + mocker.patch('app.service_api_client.get_service_inbound_api', return_value=inbound_url) + + page = client_request.get('main.api_callbacks', + service_id=service_one['id'], + _follow_redirects=True) + expected_rows = [ + expected_1st_table_row, + expected_2nd_table_row, + ] + rows = page.select('tbody tr') + assert len(rows) == 2 + for index, row in enumerate(expected_rows): + assert row == normalize_spaces(rows[index].text) diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index a98a4b72a..66163724b 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -616,7 +616,6 @@ def test_usage_page( assert '249,860 free text messages' in table assert '40 free text messages' in table assert '960 text messages at 1.65p' in table - assert 'April' in table assert 'February' in table assert 'March' in table @@ -626,6 +625,50 @@ def test_usage_page( assert '1,230 text messages at 1.65p' in table +@freeze_time("2012-03-31 12:12:12") +def test_usage_page_with_letters( + logged_in_client, + service_one, + mock_get_usage, + mock_get_billable_units, + mock_get_free_sms_fragment_limit +): + service_one['permissions'].append('letter') + response = logged_in_client.get(url_for('main.usage', service_id=SERVICE_ONE_ID)) + + assert response.status_code == 200 + + mock_get_billable_units.assert_called_once_with(SERVICE_ONE_ID, 2011) + mock_get_usage.assert_called_once_with(SERVICE_ONE_ID, 2011) + mock_get_free_sms_fragment_limit.assert_called_with(SERVICE_ONE_ID, 2011) + + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + + cols = page.find_all('div', {'class': 'column-one-third'}) + nav = page.find('ul', {'class': 'pill', 'role': 'tablist'}) + nav_links = nav.find_all('a') + + assert normalize_spaces(nav_links[0].text) == '2010 to 2011 financial year' + assert normalize_spaces(nav.find('li', {'aria-selected': 'true'}).text) == '2011 to 2012 financial year' + assert normalize_spaces(nav_links[1].text) == '2012 to 2013 financial year' + assert '252,190' in cols[1].text + assert 'Text messages' in cols[1].text + + table = page.find('table').text.strip() + + assert '249,860 free text messages' in table + assert '40 free text messages' in table + assert '960 text messages at 1.65p' in table + assert 'April' in table + assert 'February' in table + assert 'March' in table + assert '£18.94' in table + assert '140 free text messages' in table + assert '£20.30' in table + assert '1,230 text messages at 1.65p' in table + assert '10 letters at 31p' in table + + def test_usage_page_with_year_argument( logged_in_client, mock_get_usage, @@ -986,18 +1029,18 @@ def test_get_free_paid_breakdown_for_billable_units(now, expected_number_of_mont ] ) assert list(billing_units) == [ - {'free': 100000, 'name': 'April', 'paid': 0}, - {'free': 100000, 'name': 'May', 'paid': 0}, - {'free': 50000, 'name': 'June', 'paid': 50000}, - {'free': 0, 'name': 'July', 'paid': 0}, - {'free': 0, 'name': 'August', 'paid': 0}, - {'free': 0, 'name': 'September', 'paid': 0}, - {'free': 0, 'name': 'October', 'paid': 0}, - {'free': 0, 'name': 'November', 'paid': 0}, - {'free': 0, 'name': 'December', 'paid': 0}, - {'free': 0, 'name': 'January', 'paid': 0}, - {'free': 0, 'name': 'February', 'paid': 2000}, - {'free': 0, 'name': 'March', 'paid': 0} + {'free': 100000, 'name': 'April', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 100000, 'name': 'May', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 50000, 'name': 'June', 'paid': 50000, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'July', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'August', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'September', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'October', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'November', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'December', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'January', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'February', 'paid': 2000, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0}, + {'free': 0, 'name': 'March', 'paid': 0, 'letter_total': 0, 'letters': [], 'letter_cumulative': 0} ][:expected_number_of_months] diff --git a/tests/app/main/views/test_jobs.py b/tests/app/main/views/test_jobs.py index 43a9c784e..2b812ca3f 100644 --- a/tests/app/main/views/test_jobs.py +++ b/tests/app/main/views/test_jobs.py @@ -97,7 +97,7 @@ def test_should_show_page_for_one_job( job_id=fake_uuid, status=status_argument, ) - csv_link = page.find('a', {'download': 'download'}) + csv_link = page.select_one('a[download]') assert csv_link['href'] == url_for( 'main.view_job_csv', service_id=service_one['id'], diff --git a/tests/app/main/views/test_notifications.py b/tests/app/main/views/test_notifications.py index ff2e94890..2ef218748 100644 --- a/tests/app/main/views/test_notifications.py +++ b/tests/app/main/views/test_notifications.py @@ -109,13 +109,19 @@ def test_notification_page_doesnt_link_to_template_in_tour( @freeze_time("2016-01-01 01:01") -def test_notification_page_shows_status_of_letter_notification( +def test_notification_page_shows_page_for_letter_notification( client_request, mocker, fake_uuid, ): + count_of_pages = 3 + mock_get_notification(mocker, fake_uuid, template_type='letter') + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=count_of_pages + ) page = client_request.get( 'main.view_notification', @@ -131,6 +137,15 @@ def test_notification_page_shows_status_of_letter_notification( ) assert page.select('p.notification-status') == [] + letter_images = page.select('main img') + + assert len(letter_images) == count_of_pages + + for index in range(1, count_of_pages + 1): + assert page.select('img')[index]['src'].endswith( + '.png?page={}'.format(index) + ) + @pytest.mark.parametrize('filetype', [ 'pdf', 'png' @@ -193,6 +208,10 @@ def test_notification_page_has_link_to_send_another_for_sms( service_one['permissions'] = service_permissions mock_get_notification(mocker, fake_uuid, template_type=template_type) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) page = client_request.get( 'main.view_notification', @@ -237,6 +256,10 @@ def test_notification_page_has_link_to_download_letter( ): mock_get_notification(mocker, fake_uuid, template_type=template_type) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) page = client_request.get( 'main.view_notification', diff --git a/tests/app/main/views/test_register.py b/tests/app/main/views/test_register.py index ba5cc3b9e..b5ba1d508 100644 --- a/tests/app/main/views/test_register.py +++ b/tests/app/main/views/test_register.py @@ -39,6 +39,10 @@ def test_logged_in_user_redirects_to_choose_service( '+4407700900460', '+1800-555-555', ]) +@pytest.mark.parametrize('password', [ + 'the quick brown fox', + ' the quick brown fox ', +]) def test_register_creates_new_user_and_redirects_to_continue_page( client, mock_send_verify_code, @@ -48,11 +52,12 @@ def test_register_creates_new_user_and_redirects_to_continue_page( mock_send_verify_email, mock_login, phone_number_to_register_with, + password, ): user_data = {'name': 'Some One Valid', 'email_address': 'notfound@example.gov.uk', 'mobile_number': phone_number_to_register_with, - 'password': 'validPassword!', + 'password': password, 'auth_type': 'sms_auth' } diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index f3618f384..bc7423dea 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -352,6 +352,16 @@ def test_upload_csvfile_with_errors_shows_check_page_with_errors( 'Skip to file contents' ) ), + ( + """ + phone number, name + """, + ( + 'Your file is missing some rows ' + 'It needs at least one row of data. ' + 'Skip to file contents' + ) + ), ( "+447700900986", ( @@ -440,34 +450,76 @@ def test_upload_csv_invalid_extension( assert "invalid.txt isn’t a spreadsheet that Notify can read" in resp.get_data(as_text=True) -def test_upload_valid_csv_shows_file_contents( - logged_in_client, - mocker, +def test_upload_valid_csv_redirects_to_check_page( + client_request, mock_get_service_template_with_placeholders, mock_s3_upload, + fake_uuid, +): + client_request.post( + 'main.send_messages', service_id=SERVICE_ONE_ID, template_id=fake_uuid, + _data={'file': (BytesIO(''.encode('utf-8')), 'valid.csv')}, + _expected_status=302, + expected_redirect='foo' + ) + + +@pytest.mark.parametrize('extra_args, expected_recipient, expected_message', [ + ( + {}, + 'To: 07700900001', + 'Test Service: A, Template content with & entity', + ), + ( + {'row_index': 0}, + 'To: 07700900001', + 'Test Service: A, Template content with & entity', + ), + ( + {'row_index': 2}, + 'To: 07700900003', + 'Test Service: C, Template content with & entity', + ), +]) +def test_upload_valid_csv_shows_preview_and_table( + client_request, + mocker, + mock_get_live_service, + mock_get_service_template_with_placeholders, mock_get_users_by_service, mock_get_detailed_service_for_today, fake_uuid, + extra_args, + expected_recipient, + expected_message, ): + with client_request.session_transaction() as session: + session['upload_data'] = {'template_id': fake_uuid} + mocker.patch('app.main.views.send.s3download', return_value=""" phone number,name,thing,thing,thing - 07700900986, Jo, foo, foo, foo + 07700900001, A, foo, foo, foo + 07700900002, B, foo, foo, foo + 07700900003, C, foo, foo, foo """) - response = logged_in_client.post( - url_for('main.send_messages', service_id=SERVICE_ONE_ID, template_id=fake_uuid), - data={'file': (BytesIO(''.encode('utf-8')), 'valid.csv')}, - follow_redirects=True, + page = client_request.get( + 'main.check_messages', + service_id=SERVICE_ONE_ID, + template_type='sms', + upload_id=fake_uuid, + **extra_args ) - assert response.status_code == 200 - page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') assert page.h1.text.strip() == 'Preview of Two week reminder' + assert page.select_one('.sms-message-recipient').text.strip() == expected_recipient + assert page.select_one('.sms-message-wrapper').text.strip() == expected_message + for index, cell in enumerate([ ' 2 ', - '
07700900986
', - '
Jo
', + '
07700900001
', + '
A
', ( ' ' '
' @@ -481,6 +533,43 @@ def test_upload_valid_csv_shows_file_contents( assert normalize_spaces(str(page.select('table tbody td')[index])) == cell +@pytest.mark.parametrize('row_index, expected_status', [ + (0, 200), + (2, 200), + (3, 404), +]) +def test_404_for_previewing_a_row_out_of_range( + client_request, + mocker, + mock_get_live_service, + mock_get_service_template_with_placeholders, + mock_get_users_by_service, + mock_get_detailed_service_for_today, + fake_uuid, + row_index, + expected_status, +): + + with client_request.session_transaction() as session: + session['upload_data'] = {'template_id': fake_uuid} + + mocker.patch('app.main.views.send.s3download', return_value=""" + phone number,name,thing,thing,thing + 07700900001, A, foo, foo, foo + 07700900002, B, foo, foo, foo + 07700900003, C, foo, foo, foo + """) + + client_request.get( + 'main.check_messages', + service_id=SERVICE_ONE_ID, + template_type='sms', + upload_id=fake_uuid, + row_index=row_index, + _expected_status=expected_status, + ) + + def test_send_test_doesnt_show_file_contents( logged_in_client, mocker, @@ -1373,6 +1462,20 @@ def test_can_start_letters_job( @pytest.mark.parametrize('filetype', ['pdf', 'png']) +@pytest.mark.parametrize('extra_args, expected_values', [ + ( + {}, + {'postcode': 'abc123', 'addressline1': '123 street'}, + ), + ( + {'row_index': 0}, + {'postcode': 'abc123', 'addressline1': '123 street'}, + ), + ( + {'row_index': 1}, + {'postcode': 'cba321', 'addressline1': '321 avenue'}, + ), +]) def test_should_show_preview_letter_message( filetype, logged_in_platform_admin_client, @@ -1382,6 +1485,8 @@ def test_should_show_preview_letter_message( service_one, fake_uuid, mocker, + extra_args, + expected_values, ): service_one['permissions'] = ['letter'] mocker.patch('app.service_api_client.get_service', return_value={"data": service_one}) @@ -1391,7 +1496,8 @@ def test_should_show_preview_letter_message( 'app.main.views.send.s3download', return_value='\n'.join( ['address line 1, postcode'] + - ['123 street, abc123'] + ['123 street, abc123'] + + ['321 avenue, cba321'] ) ) mocked_preview = mocker.patch( @@ -1414,7 +1520,8 @@ def test_should_show_preview_letter_message( service_id=service_id, template_type='letter', upload_id=fake_uuid, - filetype=filetype + filetype=filetype, + **extra_args ) ) @@ -1425,6 +1532,7 @@ def test_should_show_preview_letter_message( assert mocked_preview.call_args[0][0].id == template_id assert type(mocked_preview.call_args[0][0]) == LetterPreviewTemplate assert mocked_preview.call_args[0][1] == filetype + assert mocked_preview.call_args[0][0].values == expected_values def test_dont_show_preview_letter_templates_for_bad_filetype( diff --git a/tests/app/main/views/test_service_settings.py b/tests/app/main/views/test_service_settings.py index 9f79cfb8d..02d22db84 100644 --- a/tests/app/main/views/test_service_settings.py +++ b/tests/app/main/views/test_service_settings.py @@ -7,7 +7,6 @@ from flask import url_for from werkzeug.exceptions import InternalServerError import app -from app.main.views.service_settings import dummy_bearer_token from app.utils import email_safe from tests import validate_route_permission, service_json from tests.conftest import ( @@ -136,7 +135,6 @@ def test_should_show_overview( 'Text messages start with service name On Change', 'International text messages On Change', 'Receive text messages On Change', - 'Callback URL for received text messages Not set Change', 'Label Value Action', 'Send letters Off Change', @@ -186,47 +184,6 @@ def test_should_show_overview_for_service_with_more_things_set( assert row == " ".join(page.find_all('tr')[index + 1].text.split()) -@pytest.mark.parametrize('url, elided_url', [ - ('https://test.url.com/inbound', 'https://test.url.com...'), - ('https://test.url.com/', 'https://test.url.com...'), - ('https://test.url.com', 'https://test.url.com'), -]) -def test_service_settings_show_elided_api_url_if_needed( - logged_in_platform_admin_client, - service_one, - single_reply_to_email_address, - single_sms_sender, - single_letter_contact_block, - mocker, - fake_uuid, - url, - elided_url, - mock_get_service_settings_page_common, -): - service_one['permissions'] = ['sms', 'email', 'inbound_sms'] - service_one['inbound_api'] = [fake_uuid] - - mocked_get_fn = mocker.patch( - 'app.service_api_client.get', - return_value={'data': {'id': fake_uuid, 'url': url}} - ) - - response = logged_in_platform_admin_client.get( - url_for( - 'main.service_settings', - service_id=service_one['id'] - ) - ) - assert response.status_code == 200 - page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') - - non_empty_trs = [tr.find_all('td') for tr in page.find_all('tr') if tr.find_all('td')] - api_url = [api_setting[1].text.strip() for api_setting in non_empty_trs - if api_setting[0].text.strip() == 'Callback URL for received text messages'][0] - assert api_url == elided_url - assert mocked_get_fn.called is True - - def test_if_cant_send_letters_then_cant_see_letter_contact_block( logged_in_client, service_one, @@ -1274,34 +1231,6 @@ def test_does_not_show_research_mode_indicator( assert not element -@pytest.mark.parametrize('url, bearer_token, expected_errors', [ - ("", "", "Can’t be empty Can’t be empty"), - ("http://not_https.com", "1234567890", "Must be a valid https URL"), - ("https://test.com", "123456789", "Must be at least 10 characters"), -]) -def test_set_inbound_api_validation( - logged_in_client, - mock_update_service, - service_one, - mock_get_letter_organisations, - url, - bearer_token, - expected_errors, -): - service_one['permissions'] = ['inbound_sms'] - response = logged_in_client.post(url_for( - 'main.service_set_inbound_api', - service_id=service_one['id']), - data={"url": url, "bearer_token": bearer_token} - ) - page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') - error_msgs = ' '.join(msg.text.strip() for msg in page.select(".error-message")) - - assert response.status_code == 200 - assert error_msgs == expected_errors - assert not mock_update_service.called - - @pytest.mark.parametrize('method', ['get', 'post']) def test_cant_set_letter_contact_block_if_service_cant_send_letters( logged_in_client, @@ -1707,113 +1636,6 @@ def test_switch_service_enable_international_sms( assert mocked_fn.call_args[0][0] == service_one['id'] -def test_set_new_inbound_api_and_valid_bearer_token_calls_create_inbound_api_endpoint( - logged_in_platform_admin_client, - service_one, - mocker, -): - service_one['permissions'] = ['inbound_sms'] - service_one['inbound_api'] = [] - - mocked_post_fn = mocker.patch('app.service_api_client.post', return_value=service_one) - - inbound_api_data = {'url': "https://test.url.com/", 'bearer_token': '1234567890'} - response = logged_in_platform_admin_client.post( - url_for( - 'main.service_set_inbound_api', - service_id=service_one['id'] - ), - data=inbound_api_data - ) - assert response.status_code == 302 - assert response.location == url_for('main.service_settings', service_id=service_one['id'], _external=True) - assert mocked_post_fn.called - - inbound_api_data['updated_by_id'] = service_one['users'][0] - assert mocked_post_fn.call_args == call("/service/{}/inbound-api".format(service_one['id']), inbound_api_data) - - -@pytest.mark.parametrize( - 'inbound_api_data', [ - {'url': "https://test.url.com/inbound", 'bearer_token': dummy_bearer_token}, - {'url': "https://test.url.com/inbound", 'bearer_token': '1234567890'}, - {'url': "https://test.url.com/", 'bearer_token': 'new_1234567890'}, - ] -) -def test_update_inbound_api_and_valid_bearer_token_calls_update_inbound_api_endpoint( - logged_in_platform_admin_client, - service_one, - mocker, - fake_uuid, - inbound_api_data, -): - service_one['permissions'] = ['inbound_sms'] - service_one['inbound_api'] = [fake_uuid] - - initial_api_data = {'data': {'id': fake_uuid, 'url': "https://test.url.com/"}} - - mocked_get_fn = mocker.patch('app.service_api_client.get', return_value=initial_api_data) - mocked_post_fn = mocker.patch('app.service_api_client.post', return_value=service_one) - - response = logged_in_platform_admin_client.get( - url_for( - 'main.service_set_inbound_api', - service_id=service_one['id'] - ) - ) - page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') - - assert page.find('input', {'id': 'url'}).get('value') == initial_api_data['data']['url'] - assert page.find('input', {'id': 'bearer_token'}).get('value') == dummy_bearer_token - - response = logged_in_platform_admin_client.post( - url_for( - 'main.service_set_inbound_api', - service_id=service_one['id'] - ), - data=inbound_api_data - ) - assert response.status_code == 302 - assert response.location == url_for('main.service_settings', service_id=service_one['id'], _external=True) - assert mocked_get_fn.called is True - assert mocked_post_fn.called is True - - if inbound_api_data['bearer_token'] == dummy_bearer_token: - del inbound_api_data['bearer_token'] - inbound_api_data['updated_by_id'] = service_one['users'][0] - - assert mocked_post_fn.call_args == call( - "/service/{}/inbound-api/{}".format(service_one['id'], fake_uuid), inbound_api_data) - - -def test_save_inbound_api_without_changes_does_not_update_inbound_api( - logged_in_platform_admin_client, - service_one, - mocker, - fake_uuid, -): - service_one['permissions'] = ['inbound_sms'] - service_one['inbound_api'] = [fake_uuid] - - initial_api_data = {'data': {'id': fake_uuid, 'url': "https://test.url.com/"}} - inbound_api_data = {'url': initial_api_data['data']['url'], 'bearer_token': dummy_bearer_token} - - mocked_get_fn = mocker.patch('app.service_api_client.get', return_value=initial_api_data) - mocked_post_fn = mocker.patch('app.service_api_client.post', return_value=service_one) - - response = logged_in_platform_admin_client.post( - url_for( - 'main.service_set_inbound_api', - service_id=service_one['id'] - ), - data=inbound_api_data - ) - assert response.status_code == 302 - assert response.location == url_for('main.service_settings', service_id=service_one['id'], _external=True) - assert mocked_get_fn.called is True - assert mocked_post_fn.called is False - - def test_archive_service_after_confirm( logged_in_platform_admin_client, service_one, diff --git a/tests/app/main/views/test_sign_in.py b/tests/app/main/views/test_sign_in.py index 97b1956ac..563d29751 100644 --- a/tests/app/main/views/test_sign_in.py +++ b/tests/app/main/views/test_sign_in.py @@ -77,6 +77,10 @@ def test_logged_in_user_redirects_to_choose_service( assert response.location == url_for('main.choose_service', _external=True) +@pytest.mark.parametrize('email_address, password', [ + ('valid@example.gov.uk', 'val1dPassw0rd!'), + (' valid@example.gov.uk ', ' val1dPassw0rd! '), +]) def test_process_sms_auth_sign_in_return_2fa_template( client, api_user_active, @@ -84,14 +88,17 @@ def test_process_sms_auth_sign_in_return_2fa_template( mock_get_user, mock_get_user_by_email, mock_verify_password, + email_address, + password, ): response = client.post( url_for('main.sign_in'), data={ - 'email_address': 'valid@example.gov.uk', - 'password': 'val1dPassw0rd!'}) + 'email_address': email_address, + 'password': password}) assert response.status_code == 302 assert response.location == url_for('.two_factor', _external=True) - mock_verify_password.assert_called_with(api_user_active.id, 'val1dPassw0rd!') + mock_verify_password.assert_called_with(api_user_active.id, password) + mock_get_user_by_email.assert_called_with('valid@example.gov.uk') def test_process_email_auth_sign_in_return_2fa_template( @@ -163,3 +170,30 @@ def test_should_attempt_redirect_when_user_is_pending( 'password': 'val1dPassw0rd!'}) assert response.location == url_for('main.resend_email_verification', _external=True) assert response.status_code == 302 + + +def test_email_address_is_treated_case_insensitively_when_signing_in_as_invited_user( + client, + mocker, + mock_verify_password, + api_user_active, + sample_invite, + mock_accept_invite, + mock_send_verify_code +): + sample_invite['email_address'] = 'TEST@user.gov.uk' + + mocker.patch('app.user_api_client.get_user_by_email_or_none', return_value=api_user_active) + mocker.patch('app.main.views.sign_in._get_and_verify_user', return_value=api_user_active) + + with client.session_transaction() as session: + session['invited_user'] = sample_invite + + response = client.post( + url_for('main.sign_in'), data={ + 'email_address': 'test@user.gov.uk', + 'password': 'val1dPassw0rd!'}) + + assert mock_accept_invite.called + assert response.status_code == 302 + assert mock_send_verify_code.called diff --git a/tests/app/main/views/test_templates.py b/tests/app/main/views/test_templates.py index cc3974357..eb5ae48e0 100644 --- a/tests/app/main/views/test_templates.py +++ b/tests/app/main/views/test_templates.py @@ -198,6 +198,53 @@ def test_should_show_sms_template_with_downgraded_unicode_characters( assert rendered_msg in response.get_data(as_text=True) +def test_should_let_letter_contact_block_be_edited_if_a_letter_contact_block_exists( + mocker, + mock_get_service_letter_template, + single_letter_contact_block, + client_request, + service_one, + fake_uuid, +): + service_one['permissions'].append('letter') + mocker.patch('app.main.views.templates.get_page_count_for_letter', return_value=1) + + page = client_request.get( + 'main.view_template', + service_id=SERVICE_ONE_ID, + template_id=fake_uuid + ) + + assert page.find('a', {'class': 'edit-template-link-letter-contact'})['href'] == url_for( + '.service_edit_letter_contact', + service_id=service_one['id'], + letter_contact_id='1234', + from_template=fake_uuid) + + +def test_should_let_letter_contact_block_be_added_if_no_letter_contact_blocks_exist( + mocker, + mock_get_service_letter_template, + no_letter_contact_blocks, + client_request, + service_one, + fake_uuid, +): + service_one['permissions'].append('letter') + mocker.patch('app.main.views.templates.get_page_count_for_letter', return_value=1) + + page = client_request.get( + 'main.view_template', + service_id=SERVICE_ONE_ID, + template_id=fake_uuid + ) + + assert page.find('a', {'class': 'edit-template-link-letter-contact'})['href'] == url_for( + '.service_add_letter_contact', + service_id=service_one['id'], + from_template=fake_uuid) + + def test_should_show_page_template_with_priority_select_if_platform_admin( logged_in_platform_admin_client, platform_admin_user, diff --git a/tests/app/notify_client/test_invite_client.py b/tests/app/notify_client/test_invite_client.py index 94e80863d..528c74a36 100644 --- a/tests/app/notify_client/test_invite_client.py +++ b/tests/app/notify_client/test_invite_client.py @@ -1,4 +1,40 @@ -from app.notify_client.invite_api_client import InviteApiClient +from unittest.mock import ANY +from app import invite_api_client + + +def test_client_creates_invite( + app_, + mocker, + fake_uuid, + sample_invite, +): + + mocker.patch('app.notify_client.current_user') + + mock_post = mocker.patch( + 'app.invite_api_client.post', + return_value={'data': dict.fromkeys({ + 'id', 'service', 'from_user', 'email_address', + 'permissions', 'status', 'created_at', 'auth_type' + })} + ) + + invite_api_client.create_invite( + '12345', '67890', 'test@example.com', 'send_messages', 'sms_auth' + ) + + mock_post.assert_called_once_with( + url='/service/{}/invite'.format('67890'), + data={ + 'auth_type': 'sms_auth', + 'email_address': 'test@example.com', + 'from_user': '12345', + 'service': '67890', + 'created_by': ANY, + 'permissions': 'send_messages', + 'invite_link_host': 'http://localhost:6012', + } + ) def test_client_returns_invite(mocker, sample_invite): @@ -10,10 +46,9 @@ def test_client_returns_invite(mocker, sample_invite): expected_url = '/service/{}/invite'.format(service_id) - client = InviteApiClient() mock_get = mocker.patch('app.notify_client.invite_api_client.InviteApiClient.get', return_value=expected_data) - invites = client.get_invites_for_service(service_id) + invites = invite_api_client.get_invites_for_service(service_id) mock_get.assert_called_once_with(expected_url) assert len(invites) == 1 diff --git a/tests/conftest.py b/tests/conftest.py index 004e87052..3e9c5c466 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2155,7 +2155,14 @@ def mock_get_billable_units(mocker): 'rate': 0.0165, 'billing_units': 100 }, - + { + 'month': 'February', + 'international': False, + 'rate_multiplier': 1, + 'notification_type': 'letter', + 'rate': 0.31, + 'billing_units': 10 + } ] return mocker.patch( @@ -2497,3 +2504,65 @@ def valid_token(app_, fake_uuid): app_.config['SECRET_KEY'], app_.config['DANGEROUS_SALT'] ) + + +@pytest.fixture(scope='function') +def mock_get_valid_service_inbound_api(mocker): + def _get(service_id, inbound_api_id): + return { + 'created_at': '2017-12-04T10:52:55.289026Z', + 'updated_by_id': fake_uuid, + 'id': inbound_api_id, + 'url': 'https://hello3.gov.uk', + 'service_id': service_id, + 'updated_at': '2017-12-04T11:28:42.575153Z' + } + + return mocker.patch('app.service_api_client.get_service_inbound_api', side_effect=_get) + + +@pytest.fixture(scope='function') +def mock_get_valid_service_callback_api(mocker): + def _get(service_id, callback_api_id): + return { + 'created_at': '2017-12-04T10:52:55.289026Z', + 'updated_by_id': fake_uuid, + 'id': callback_api_id, + 'url': 'https://hello2.gov.uk', + 'service_id': service_id, + 'updated_at': '2017-12-04T11:28:42.575153Z' + } + + return mocker.patch('app.service_api_client.get_service_callback_api', side_effect=_get) + + +@pytest.fixture(scope='function') +def mock_create_service_inbound_api(mocker): + def _create_service_inbound_api(service_id, url, bearer_token, user_id): + return + + return mocker.patch('app.service_api_client.create_service_inbound_api', side_effect=_create_service_inbound_api) + + +@pytest.fixture(scope='function') +def mock_update_service_inbound_api(mocker): + def _update_service_inbound_api(service_id, url, bearer_token, user_id, inbound_api_id): + return + + return mocker.patch('app.service_api_client.update_service_inbound_api', side_effect=_update_service_inbound_api) + + +@pytest.fixture(scope='function') +def mock_create_service_callback_api(mocker): + def _create_service_callback_api(service_id, url, bearer_token, user_id): + return + + return mocker.patch('app.service_api_client.create_service_callback_api', side_effect=_create_service_callback_api) + + +@pytest.fixture(scope='function') +def mock_update_service_callback_api(mocker): + def _update_service_callback_api(service_id, url, bearer_token, user_id, callback_api_id): + return + + return mocker.patch('app.service_api_client.update_service_callback_api', side_effect=_update_service_callback_api)