diff --git a/app/assets/stylesheets/components/navigation.scss b/app/assets/stylesheets/components/navigation.scss index 6f1ce23b6..8eafd8c49 100644 --- a/app/assets/stylesheets/components/navigation.scss +++ b/app/assets/stylesheets/components/navigation.scss @@ -15,24 +15,40 @@ } &-switch { + @include core-16; text-align: right; position: absolute; top: 0; right: 0; padding: 13px 0 9px 15px; + + &:focus { + outline: none; + border-bottom: 4px solid $yellow; + border-left: 10px solid $yellow; + border-right: 3px solid $yellow; + right: -3px; + color: $text-colour; + } + } } li { @include core-19; - margin: 10px 0 0 0; + margin: 0; list-style-type: none; } a { + display: block; + padding: 5px 0; + position: relative; + top: 5px; + &:link, &:visited { text-decoration: none; @@ -43,6 +59,10 @@ text-decoration: underline; } + &:focus { + color: $text-colour; + } + } } diff --git a/app/config.py b/app/config.py index eabd7391f..d05be8647 100644 --- a/app/config.py +++ b/app/config.py @@ -85,8 +85,11 @@ class Config(object): r"assembly\.wales", r"cjsm\.net", r"cqc\.org\.uk", + r"bl\.uk", ] + LOGO_UPLOAD_BUCKET_NAME = 'public-logos-local' + class Development(Config): DEBUG = True @@ -94,6 +97,7 @@ class Development(Config): SESSION_PROTECTION = None STATSD_ENABLED = False CSV_UPLOAD_BUCKET_NAME = 'development-notifications-csv-upload' + LOGO_UPLOAD_BUCKET_NAME = 'public-logos-tools' class Test(Development): @@ -102,6 +106,7 @@ class Test(Development): STATSD_ENABLED = True WTF_CSRF_ENABLED = False CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload' + LOGO_UPLOAD_BUCKET_NAME = 'public-logos-test' NOTIFY_ENVIRONMENT = 'test' TEMPLATE_PREVIEW_API_HOST = 'http://localhost:9999' @@ -111,6 +116,7 @@ class Preview(Config): HEADER_COLOUR = '#F499BE' # $baby-pink STATSD_ENABLED = True CSV_UPLOAD_BUCKET_NAME = 'preview-notifications-csv-upload' + LOGO_UPLOAD_BUCKET_NAME = 'public-logos-preview' NOTIFY_ENVIRONMENT = 'preview' @@ -120,6 +126,7 @@ class Staging(Config): HEADER_COLOUR = '#6F72AF' # $mauve STATSD_ENABLED = True CSV_UPLOAD_BUCKET_NAME = 'staging-notify-csv-upload' + LOGO_UPLOAD_BUCKET_NAME = 'public-logos-staging' NOTIFY_ENVIRONMENT = 'staging' @@ -129,6 +136,7 @@ class Live(Config): HTTP_PROTOCOL = 'https' STATSD_ENABLED = True CSV_UPLOAD_BUCKET_NAME = 'live-notifications-csv-upload' + LOGO_UPLOAD_BUCKET_NAME = 'public-logos-production' NOTIFY_ENVIRONMENT = 'live' @@ -142,6 +150,7 @@ class Sandbox(CloudFoundryConfig): HEADER_COLOUR = '#F499BE' # $baby-pink STATSD_ENABLED = True CSV_UPLOAD_BUCKET_NAME = 'cf-sandbox-notifications-csv-upload' + LOGO_UPLOAD_BUCKET_NAME = 'cf-sandbox-notifications-logo-upload' NOTIFY_ENVIRONMENT = 'sandbox' diff --git a/app/main/__init__.py b/app/main/__init__.py index 553238962..1a7d56f17 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -28,6 +28,7 @@ from app.main.views import ( providers, platform_admin, letter_jobs, + organisations, conversation, notifications, inbound_number diff --git a/app/main/forms.py b/app/main/forms.py index 0b92466dd..45ba28782 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -3,6 +3,7 @@ import pytz from flask_wtf import FlaskForm as Form from datetime import datetime, timedelta + from notifications_utils.recipients import ( validate_phone_number, InvalidPhoneError @@ -25,6 +26,7 @@ from wtforms import ( SelectField) from wtforms.fields.html5 import EmailField, TelField, SearchField from wtforms.validators import (DataRequired, Email, Length, Regexp, Optional) +from flask_wtf.file import FileField as FileField_wtf, FileAllowed from app.main.validators import (Blacklist, CsvFileValidator, ValidGovEmail, NoCommasInPlaceHolders, OnlyGSMCharacters) @@ -210,38 +212,8 @@ class TextNotReceivedForm(Form): mobile_number = mobile_number() -class AddServiceForm(Form): - def __init__(self, names_func, *args, **kwargs): - """ - Keyword arguments: - names_func -- Returns a list of unique service_names already registered - on the system. - """ - self._names_func = names_func - super(AddServiceForm, self).__init__(*args, **kwargs) - - name = StringField( - 'Service name', - validators=[ - DataRequired(message='Can’t be empty') - ] - ) - - def validate_name(self, a): - from app.utils import email_safe - # make sure the email_from will be unique to all services - if email_safe(a.data) in self._names_func(): - raise ValidationError('This service name is already in use') - - class ServiceNameForm(Form): - def __init__(self, names_func, *args, **kwargs): - """ - Keyword arguments: - names_func -- Returns a list of unique service_names already registered - on the system. - """ - self._names_func = names_func + def __init__(self, *args, **kwargs): super(ServiceNameForm, self).__init__(*args, **kwargs) name = StringField( @@ -250,12 +222,6 @@ class ServiceNameForm(Form): DataRequired(message='Can’t be empty') ]) - def validate_name(self, a): - from app.utils import email_safe - # make sure the email_from will be unique to all services - if email_safe(a.data) in self._names_func(): - raise ValidationError('This service name is already in use') - class ConfirmPasswordForm(Form): def __init__(self, validate_password_func, *args, **kwargs): @@ -559,6 +525,28 @@ class ServiceBrandingOrg(Form): ) +class ServiceSelectOrg(Form): + + def __init__(self, organisations=[], *args, **kwargs): + self.organisation.choices = organisations + super(ServiceSelectOrg, self).__init__(*args, **kwargs) + + organisation = RadioField( + 'Organisation', + validators=[ + DataRequired() + ] + ) + + +class ServiceManageOrg(Form): + + name = StringField('Name') + + colour = StringField('Colour', render_kw={'onkeyup': 'update_colour_span()', 'onblur': 'update_colour_span()'}) + file = FileField_wtf('Upload a PNG logo', validators=[FileAllowed(['png'], 'PNG Images only!')]) + + class LetterBranding(Form): def __init__(self, choices=[], *args, **kwargs): diff --git a/app/main/s3_client.py b/app/main/s3_client.py new file mode 100644 index 000000000..75404ac5e --- /dev/null +++ b/app/main/s3_client.py @@ -0,0 +1,102 @@ +import uuid +import botocore +from boto3 import resource +from flask import current_app +from notifications_utils.s3 import s3upload as utils_s3upload + +FILE_LOCATION_STRUCTURE = 'service-{}-notify/{}.csv' +TEMP_TAG = 'temp-{user_id}_' +LOGO_LOCATION_STRUCTURE = '{temp}{unique_id}-{filename}' + + +def get_s3_object(bucket_name, filename): + s3 = resource('s3') + return s3.Object(bucket_name, filename) + + +def delete_s3_object(filename): + bucket_name = current_app.config['LOGO_UPLOAD_BUCKET_NAME'] + get_s3_object(bucket_name, filename).delete() + + +def rename_s3_object(old_name, new_name): + bucket_name = current_app.config['LOGO_UPLOAD_BUCKET_NAME'] + get_s3_object(bucket_name, new_name).copy_from( + CopySource='{}/{}'.format(bucket_name, old_name)) + delete_s3_object(old_name) + + +def get_s3_objects_filter_by_prefix(prefix): + bucket_name = current_app.config['LOGO_UPLOAD_BUCKET_NAME'] + s3 = resource('s3') + return s3.Bucket(bucket_name).objects.filter(Prefix=prefix) + + +def get_temp_truncated_filename(filename, user_id): + return filename[len(TEMP_TAG.format(user_id=user_id)):] + + +def s3upload(service_id, filedata, region): + upload_id = str(uuid.uuid4()) + upload_file_name = FILE_LOCATION_STRUCTURE.format(service_id, upload_id) + utils_s3upload(filedata=filedata['data'], + region=region, + bucket_name=current_app.config['CSV_UPLOAD_BUCKET_NAME'], + file_location=upload_file_name) + return upload_id + + +def s3download(service_id, upload_id): + contents = '' + try: + bucket_name = current_app.config['CSV_UPLOAD_BUCKET_NAME'] + upload_file_name = FILE_LOCATION_STRUCTURE.format(service_id, upload_id) + key = get_s3_object(bucket_name, upload_file_name) + contents = key.get()['Body'].read().decode('utf-8') + except botocore.exceptions.ClientError as e: + current_app.logger.error("Unable to download s3 file {}".format( + FILE_LOCATION_STRUCTURE.format(service_id, upload_id))) + raise e + return contents + + +def upload_logo(filename, filedata, region, user_id): + upload_file_name = LOGO_LOCATION_STRUCTURE.format( + temp=TEMP_TAG.format(user_id=user_id), + unique_id=str(uuid.uuid4()), + filename=filename + ) + bucket_name = current_app.config['LOGO_UPLOAD_BUCKET_NAME'] + utils_s3upload( + filedata=filedata, + region=region, + bucket_name=bucket_name, + file_location=upload_file_name, + content_type='image/png' + ) + + return upload_file_name + + +def persist_logo(filename, user_id): + if filename.startswith(TEMP_TAG.format(user_id=user_id)): + persisted_filename = get_temp_truncated_filename( + filename=filename, user_id=user_id) + else: + return filename + + rename_s3_object(filename, persisted_filename) + + return persisted_filename + + +def delete_temp_files_created_by(user_id): + for obj in get_s3_objects_filter_by_prefix(TEMP_TAG.format(user_id=user_id)): + delete_s3_object(obj.key) + + +def delete_temp_file(filename): + if not filename.startswith(TEMP_TAG[:5]): + raise ValueError('Not a temp file: {}'.format(filename)) + + delete_s3_object(filename) diff --git a/app/main/uploader.py b/app/main/uploader.py deleted file mode 100644 index 4c00fbce5..000000000 --- a/app/main/uploader.py +++ /dev/null @@ -1,32 +0,0 @@ -import uuid -import botocore -from boto3 import resource -from flask import current_app -from notifications_utils.s3 import s3upload as utils_s3upload - -FILE_LOCATION_STRUCTURE = 'service-{}-notify/{}.csv' - - -def s3upload(service_id, filedata, region): - upload_id = str(uuid.uuid4()) - upload_file_name = FILE_LOCATION_STRUCTURE.format(service_id, upload_id) - utils_s3upload(filedata=filedata['data'], - region=region, - bucket_name=current_app.config['CSV_UPLOAD_BUCKET_NAME'], - file_location=upload_file_name) - return upload_id - - -def s3download(service_id, upload_id): - contents = '' - try: - s3 = resource('s3') - bucket_name = current_app.config['CSV_UPLOAD_BUCKET_NAME'] - upload_file_name = FILE_LOCATION_STRUCTURE.format(service_id, upload_id) - key = s3.Object(bucket_name, upload_file_name) - contents = key.get()['Body'].read().decode('utf-8') - except botocore.exceptions.ClientError as e: - current_app.logger.error("Unable to download s3 file {}".format( - FILE_LOCATION_STRUCTURE.format(service_id, upload_id))) - raise e - return contents diff --git a/app/main/views/add_service.py b/app/main/views/add_service.py index 115ca77c5..9b7d5b9fd 100644 --- a/app/main/views/add_service.py +++ b/app/main/views/add_service.py @@ -10,11 +10,11 @@ from flask_login import ( current_user, login_required ) - +from notifications_python_client.errors import HTTPError from werkzeug.exceptions import abort from app.main import main -from app.main.forms import AddServiceForm +from app.main.forms import ServiceNameForm from app.notify_client.models import InvitedUser from app import ( @@ -39,14 +39,32 @@ def _add_invited_user_to_service(invited_user): return service_id -def _create_service(service_name, email_from): - service_id = service_api_client.create_service(service_name=service_name, - message_limit=current_app.config['DEFAULT_SERVICE_LIMIT'], - restricted=True, - user_id=session['user_id'], - email_from=email_from) - session['service_id'] = service_id - return service_id +def _create_service(service_name, email_from, form): + try: + service_id = service_api_client.create_service(service_name=service_name, + message_limit=current_app.config['DEFAULT_SERVICE_LIMIT'], + restricted=True, + user_id=session['user_id'], + email_from=email_from) + session['service_id'] = service_id + return service_id, None + except HTTPError as e: + if e.status_code == 400 and e.message['name']: + form.name.errors.append("This service name is already in use") + return None, e + else: + raise e + + +def _create_example_template(service_id): + example_sms_template = service_api_client.create_service_template( + 'Example text message template', + 'sms', + 'Hey ((name)), I’m trying out Notify. Today is ((day of week)) and my favourite colour is ((colour)).', + service_id, + process_type='priority', + ) + return example_sms_template @main.route("/add-service", methods=['GET', 'POST']) @@ -60,24 +78,20 @@ def add_service(): if not is_gov_user(current_user.email_address): abort(403) - form = AddServiceForm(service_api_client.find_all_service_email_from) + form = ServiceNameForm() heading = 'Which service do you want to set up notifications for?' if form.validate_on_submit(): email_from = email_safe(form.name.data) service_name = form.name.data - service_id = _create_service(service_name, email_from) - if (len(service_api_client.get_active_services({'user_id': session['user_id']}).get('data', [])) > 1): + service_id, error = _create_service(service_name, email_from, form) + if error: + return render_template('views/add-service.html', form=form, heading=heading) + if len(service_api_client.get_active_services({'user_id': session['user_id']}).get('data', [])) > 1: return redirect(url_for('main.service_dashboard', service_id=service_id)) - example_sms_template = service_api_client.create_service_template( - 'Example text message template', - 'sms', - 'Hey ((name)), I’m trying out Notify. Today is ((day of week)) and my favourite colour is ((colour)).', - service_id, - process_type='priority', - ) + example_sms_template = _create_example_template(service_id) return redirect(url_for( 'main.start_tour', diff --git a/app/main/views/organisations.py b/app/main/views/organisations.py new file mode 100644 index 000000000..0fe976752 --- /dev/null +++ b/app/main/views/organisations.py @@ -0,0 +1,97 @@ +from flask import current_app, redirect, render_template, session, url_for, request +from flask_login import login_required + +from app import organisations_client +from app.main import main +from app.main.forms import ( + ServiceSelectOrg, + ServiceManageOrg) +from app.utils import user_has_permissions, get_cdn_domain +from app.main.s3_client import ( + TEMP_TAG, + upload_logo, + delete_temp_file, + delete_temp_files_created_by, + persist_logo +) +from app.main.views.service_settings import get_branding_as_value_and_label, get_branding_as_dict + + +@main.route("/organisations", methods=['GET', 'POST']) +@main.route("/organisations/", methods=['GET', 'POST']) +@login_required +@user_has_permissions(admin_override=True) +def organisations(organisation_id=None): + orgs = organisations_client.get_organisations() + + form = ServiceSelectOrg() + form.organisation.choices = get_branding_as_value_and_label(orgs) + [('None', 'Create a new organisation')] + + if form.validate_on_submit(): + if form.organisation.data != 'None': + session['organisation'] = [o for o in orgs if o['id'] == form.organisation.data][0] + elif session.get('organisation'): + del session['organisation'] + + return redirect(url_for('.manage_org')) + + form.organisation.data = organisation_id if organisation_id in [o['id'] for o in orgs] else 'None' + + return render_template( + 'views/organisations/select-org.html', + form=form, + branding_dict=get_branding_as_dict(orgs), + organisation_id=organisation_id + ) + + +@main.route("/organisations/manage", methods=['GET', 'POST']) +@main.route("/organisations/manage/", methods=['GET', 'POST']) +@login_required +@user_has_permissions(admin_override=True) +def manage_org(logo=None): + form = ServiceManageOrg() + + org = session.get("organisation") + + logo = logo if logo else org.get('logo') if org else None + + if form.validate_on_submit(): + if form.file.data: + upload_filename = upload_logo( + form.file.data.filename, + form.file.data, + current_app.config['AWS_REGION'], + user_id=session["user_id"] + ) + + if logo and logo.startswith(TEMP_TAG.format(user_id=session['user_id'])): + delete_temp_file(logo) + + return redirect( + url_for('.manage_org', logo=upload_filename)) + + logo = persist_logo(logo, session["user_id"]) + delete_temp_files_created_by(session["user_id"]) + + if org: + organisations_client.update_organisation( + org_id=org['id'], logo=logo, name=form.name.data, colour=form.colour.data) + org_id = org['id'] + else: + resp = organisations_client.create_organisation( + logo=logo, name=form.name.data, colour=form.colour.data) + org_id = resp['data']['id'] + + return redirect(url_for('.organisations', organisation_id=org_id)) + if org: + form.name.data = org['name'] + form.colour.data = org['colour'] + + return render_template( + 'views/organisations/manage-org.html', + form=form, + organisation=org, + cdn_url=get_cdn_domain(), + logo=logo + ) diff --git a/app/main/views/send.py b/app/main/views/send.py index 557faf0ba..354c5c8e2 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -33,7 +33,7 @@ from app.main.forms import ( ChooseTimeForm, get_placeholder_form_instance ) -from app.main.uploader import ( +from app.main.s3_client import ( s3upload, s3download ) diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index d38f65d29..fc68c650f 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -88,12 +88,16 @@ def service_settings(service_id): @login_required @user_has_permissions('manage_settings', admin_override=True) def service_name_change(service_id): - form = ServiceNameForm(service_api_client.find_all_service_email_from) + form = ServiceNameForm() if request.method == 'GET': form.name.data = current_service.get('name') if form.validate_on_submit(): + unique_name = service_api_client.is_service_name_unique(form.name.data, email_safe(form.name.data)) + if not unique_name: + form.name.errors.append("This service name is already in use") + return render_template('views/service-settings/name.html', form=form) session['service_name_change'] = form.name.data return redirect(url_for('.service_name_change_confirm', service_id=service_id)) diff --git a/app/notify_client/organisations_client.py b/app/notify_client/organisations_client.py index bf4717976..aae8d4c07 100644 --- a/app/notify_client/organisations_client.py +++ b/app/notify_client/organisations_client.py @@ -19,3 +19,19 @@ class OrganisationsClient(NotifyAdminAPIClient): def get_letter_organisations(self): return self.get(url='/dvla_organisations') + + def create_organisation(self, logo, name, colour): + data = { + "logo": logo, + "name": name, + "colour": colour + } + return self.post(url="/organisation", data=data) + + def update_organisation(self, org_id, logo, name, colour): + data = { + "logo": logo, + "name": name, + "colour": colour + } + return self.post(url="/organisation/{}".format(org_id), data=data) diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index b72fe77c9..2173e3402 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -216,9 +216,13 @@ class ServiceAPIClient(NotifyAdminAPIClient): data = _attach_current_user(data) return self.post(endpoint, data=data) - def find_all_service_email_from(self, user_id=None): - resp = self.get_services(user_id) - return [x['email_from'] for x in resp['data']] + def is_service_name_unique(self, name, email_from): + """ + Check that the service name or email from are unique across all services. + """ + endpoint = "/service/unique" + params = {"name": name, "email_from": email_from} + return self.get(url=endpoint, params=params)["result"] # Temp access of service history data. Includes service and api key history def get_service_history(self, service_id): diff --git a/app/templates/admin_template.html b/app/templates/admin_template.html index 215563caa..3c252ce94 100644 --- a/app/templates/admin_template.html +++ b/app/templates/admin_template.html @@ -88,6 +88,7 @@ @@ -100,7 +101,6 @@
  • Trial mode
  • Pricing
  • Delivery and failure
  • -
  • Performance
  • diff --git a/app/templates/components/page-footer.html b/app/templates/components/page-footer.html index 5dcc69e4f..74b847a0c 100644 --- a/app/templates/components/page-footer.html +++ b/app/templates/components/page-footer.html @@ -6,12 +6,13 @@ secondary_link=False, secondary_link_text=None, delete_link=False, - delete_link_text="delete" + delete_link_text="delete", + button_disabled=False ) %}