diff --git a/.travis.yml b/.travis.yml index 0ed81c30b..9ec5271d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ sudo: false language: python python: -- '3.4' +- '3.5' env: secure: jT9BIioqBMkOdLZhU+WJNdnRJ+06G7qUx4QqEVldp96dJwmWpPEvA0XbitdnQt/WXYkpMlDbgSApvvGj2ZNvdpowRRe5HFX8D2Udhi2g9+cXgKrQxH6zv0evJyQLOjCINW6KtgMCJ5wkYR3qQ4BQawlDt6ecpmeboKTmvs2W8jZ09aV4IKKvdd7BwFon10QVPF5ny10G83unLtKnKgRMjSSLnaEiA78pE/LSUkekK4mhmtl+yfQf60cIuQGcN9NCYIt5PrdYYyMkbUaht9ykwL2C11sp5JYPClI9k6lrlpGJCdL9wbJwejGhR/pEqwJ4tKK8Zv+mngmkbzE6fd5ehuRMnIUAifG4t3p6WbhKwY5pJsdVyPgWcRSPXOJA7yEcAeTAvWcC++6mCIFBeMxt/yQNw02jkFHeNKRh2twTRvr4xWZHq9FsVxTEVz89OOuue3IkkyDNmVusGJ9+AVRIn9Oa+U/r3bDnrs7jz+meSwb82GZUBzFpUe2pe8qeBE572Ay7yHB73VHUgp/2A1qkZ4SnTjTpMbnS5RdXTgwtMkOs5MLZgteCVxFL3sHcr9e/B3UIUnzKUSPXXOjHyDxBwrABWo81V9Vp2IPV7P9Ofv8zroudjQxK5MOcbmiPQF+eEB9L4DvkUBNsGxtJ/nmPp6tmN0Xjo0xXVdZCEVj29Og= before_install: diff --git a/app/__init__.py b/app/__init__.py index 77cd41c86..48d1a1df3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -18,6 +18,7 @@ from app.notify_client.user_api_client import UserApiClient from app.notify_client.job_api_client import JobApiClient from app.notify_client.status_api_client import StatusApiClient from app.notify_client.permission_api_client import PermissionApiClient +from app.notify_client.invite_api_client import InviteApiClient from app.its_dangerous_session import ItsdangerousSessionInterface from app.asset_fingerprinter import AssetFingerprinter from app.utils import validate_phone_number, InvalidPhoneError @@ -33,6 +34,7 @@ user_api_client = UserApiClient() api_key_api_client = ApiKeyApiClient() job_api_client = JobApiClient() status_api_client = StatusApiClient() +invite_api_client = InviteApiClient() asset_fingerprinter = AssetFingerprinter() permission_api_client = PermissionApiClient() @@ -52,6 +54,7 @@ def create_app(config_name, config_overrides=None): job_api_client.init_app(application) status_api_client.init_app(application) permission_api_client.init_app(application) + invite_api_client.init_app(application) login_manager.init_app(application) login_manager.login_view = 'main.sign_in' diff --git a/app/assets/stylesheets/_grids.scss b/app/assets/stylesheets/_grids.scss index 7f8eb724f..5e8643173 100644 --- a/app/assets/stylesheets/_grids.scss +++ b/app/assets/stylesheets/_grids.scss @@ -15,3 +15,7 @@ margin-bottom: $gutter; clear: both; } + +.bottom-gutter-2-3 { + margin-bottom: $gutter * 2/3; +} diff --git a/app/assets/stylesheets/components/banner.scss b/app/assets/stylesheets/components/banner.scss index c62bfe2c6..77b706779 100644 --- a/app/assets/stylesheets/components/banner.scss +++ b/app/assets/stylesheets/components/banner.scss @@ -71,7 +71,7 @@ color: $text-colour; background-image: file-url('icon-important-2x.png'); background-size: 34px 34px; - background-position: 0 0px; + background-position: 0 0; background-repeat: no-repeat; padding: 7px 0 5px 50px; } diff --git a/app/assets/stylesheets/components/email-message.scss b/app/assets/stylesheets/components/email-message.scss index b4771841a..860b3d99e 100644 --- a/app/assets/stylesheets/components/email-message.scss +++ b/app/assets/stylesheets/components/email-message.scss @@ -7,9 +7,26 @@ margin: 20px 0 10px 0; } - &-subject, - &-from { - margin: 10px 0; + &-meta { + + @include core-19; + margin: 0; + + td, + th { + @include core-19; + border-bottom: 0; + border-top: 1px solid $border-colour; + } + + th { + color: $secondary-text-colour; + } + + td { + width: 99%; + } + } &-from { diff --git a/app/assets/stylesheets/components/sms-message.scss b/app/assets/stylesheets/components/sms-message.scss index 898d04c24..cb26b296f 100644 --- a/app/assets/stylesheets/components/sms-message.scss +++ b/app/assets/stylesheets/components/sms-message.scss @@ -40,7 +40,7 @@ .sms-message-use-links { @include copy-19; - margin-top: 55px; + margin-top: 52px; a { diff --git a/app/main/__init__.py b/app/main/__init__.py index fbfd2562a..ec9691d24 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -3,7 +3,7 @@ from flask import Blueprint main = Blueprint('main', __name__) from app.main.views import ( - index, sign_in, sign_out, register, two_factor, verify, sms, add_service, + index, sign_in, sign_out, register, two_factor, verify, send, add_service, code_not_received, jobs, dashboard, templates, service_settings, forgot_password, new_password, styleguide, user_profile, choose_service, api_keys, manage_users ) diff --git a/app/main/dao/templates_dao.py b/app/main/dao/templates_dao.py index d34f2b187..7d07b7d0e 100644 --- a/app/main/dao/templates_dao.py +++ b/app/main/dao/templates_dao.py @@ -4,14 +4,14 @@ from app.utils import BrowsableItem from notifications_python_client.errors import HTTPError -def insert_service_template(name, content, service_id): +def insert_service_template(name, type_, content, service_id, subject=None): return notifications_api_client.create_service_template( - name, 'sms', content, service_id) + name, type_, content, service_id, subject) -def update_service_template(id_, name, content, service_id): +def update_service_template(id_, name, type_, content, service_id, subject=None): return notifications_api_client.update_service_template( - id_, name, 'sms', content, service_id) + id_, name, type_, content, service_id) def get_service_templates(service_id): diff --git a/app/main/forms.py b/app/main/forms.py index 20b007514..7bb000cc7 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -7,7 +7,8 @@ from wtforms import ( ValidationError, TextAreaField, FileField, - RadioField + RadioField, + BooleanField ) from wtforms.fields.html5 import EmailField, TelField from wtforms.validators import DataRequired, Email, Length, Regexp @@ -110,6 +111,7 @@ class TwoFactorForm(Form): super(TwoFactorForm, self).__init__(*args, **kwargs) sms_code = sms_code() + remember_me = BooleanField("Remember me") def validate_sms_code(self, field): is_valid, reason = self.validate_code_func(field.data) @@ -162,13 +164,13 @@ class AddServiceForm(Form): name = StringField( 'Service name', validators=[ - DataRequired(message='Service name can not be empty') + DataRequired(message='Service name can’t be empty') ] ) def validate_name(self, a): if a.data in self._names_func(): - raise ValidationError('Service name already exists') + raise ValidationError('This service name is already in use') class ServiceNameForm(Form): @@ -188,7 +190,7 @@ class ConfirmPasswordForm(Form): raise ValidationError('Invalid password') -class TemplateForm(Form): +class SMSTemplateForm(Form): name = StringField( u'Template name', validators=[DataRequired(message="Template name cannot be empty")]) @@ -198,6 +200,13 @@ class TemplateForm(Form): validators=[DataRequired(message="Template content cannot be empty")]) +class EmailTemplateForm(SMSTemplateForm): + + subject = StringField( + u'Subject', + validators=[DataRequired(message="Subject cannot be empty")]) + + class ForgotPasswordForm(Form): email_address = email_address() diff --git a/app/main/views/add_service.py b/app/main/views/add_service.py index ad5d8f715..9cee69b8a 100644 --- a/app/main/views/add_service.py +++ b/app/main/views/add_service.py @@ -11,39 +11,15 @@ from app.main.forms import AddServiceForm @login_required def add_service(): form = AddServiceForm(services_dao.find_all_service_names) - services = services_dao.get_services(current_user.id) - if len(services['data']) == 0: - heading = 'Which service do you want to set up notifications for?' - else: - heading = 'Add a new service' + heading = 'Which service do you want to set up notifications for?' if form.validate_on_submit(): session['service_name'] = form.name.data - return redirect(url_for('main.add_from_address')) + user = users_dao.get_user_by_id(session['user_id']) + service_id = services_dao.insert_new_service(session['service_name'], user.id) + return redirect(url_for('main.service_dashboard', service_id=service_id)) else: return render_template( 'views/add-service.html', form=form, heading=heading ) - - -@main.route("/confirm-add-service", methods=['GET', 'POST']) -@login_required -def add_from_address(): - if request.method == 'POST': - user = users_dao.get_user_by_id(session['user_id']) - service_id = services_dao.insert_new_service(session['service_name'], user.id) - return redirect(url_for('main.service_dashboard', service_id=service_id)) - else: - return render_template( - 'views/add-from-address.html', - service_name=session['service_name'], - from_address="{}@notifications.service.gov.uk".format(_email_safe(session['service_name'])) - ) - - -def _email_safe(string): - return "".join([ - character.lower() if character.isalnum() or character == "." else "" - for character in re.sub("\s+", ".", string.strip()) - ]) diff --git a/app/main/views/email.py b/app/main/views/email.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/main/views/index.py b/app/main/views/index.py index 1200cf65b..8335284cd 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -22,15 +22,3 @@ def register_from_invite(): @login_required def verify_mobile(): return render_template('views/verify-mobile.html') - - -@main.route("/services//send-email") -@login_required -def send_email(service_id): - return render_template('views/send-email.html', service_id=service_id) - - -@main.route("/services//check-email") -@login_required -def check_email(service_id): - return render_template('views/check-email.html') diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py index dd5e45e04..9469fa810 100644 --- a/app/main/views/jobs.py +++ b/app/main/views/jobs.py @@ -13,6 +13,7 @@ from utils.template import Template from app import job_api_client from app.main import main from app.main.dao import templates_dao +from app.main.dao import services_dao now = time.strftime('%H:%M') @@ -37,6 +38,7 @@ def view_jobs(service_id): @main.route("/services//jobs/") @login_required def view_job(service_id, job_id): + service = services_dao.get_service_by_id_or_404(service_id) try: job = job_api_client.get_job(service_id, job_id)['data'] messages = [] @@ -56,9 +58,11 @@ def view_job(service_id, job_id): uploaded_file_name=job['original_file_name'], uploaded_file_time=job['created_at'], template=Template( - templates_dao.get_service_template_or_404(service_id, job['template'])['data'] + templates_dao.get_service_template_or_404(service_id, job['template'])['data'], + prefix=service['name'] ), - service_id=service_id + service_id=service_id, + service=service ) except HTTPError as e: if e.status_code == 404: diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py index df609ee8f..0b5f0e7f0 100644 --- a/app/main/views/manage_users.py +++ b/app/main/views/manage_users.py @@ -7,13 +7,18 @@ from flask import ( flash ) -from flask_login import login_required, current_user +from flask_login import ( + login_required, + current_user +) + +from notifications_python_client.errors import HTTPError from app.main import main -from app.main.dao import users_dao from app.main.forms import InviteUserForm from app.main.dao.services_dao import get_service_by_id_or_404 from app import user_api_client +from app import invite_api_client fake_users = [ { @@ -29,13 +34,19 @@ fake_users = [ @main.route("/services//users") @login_required def manage_users(service_id): - return render_template( - 'views/manage-users.html', - service_id=service_id, - users=fake_users, - current_user=current_user, - invited_users=[] - ) + try: + users = user_api_client.get_users_for_service(service_id=service_id) + invited_users = invite_api_client.get_invites_for_service(service_id=service_id) + return render_template('views/manage-users.html', + service_id=service_id, + users=users, + current_user=current_user, + invited_users=invited_users) + except HTTPError as e: + if e.status_code == 404: + abort(404) + else: + raise e @main.route("/services//users/invite", methods=['GET', 'POST']) @@ -43,10 +54,19 @@ def manage_users(service_id): def invite_user(service_id): form = InviteUserForm() - if form.validate_on_submit(): - flash('Invite sent to {}'.format(form.email_address.data), 'default_with_tick') - return redirect(url_for('.manage_users', service_id=service_id)) + email_address = form.email_address.data + permissions = _get_permissions(request.form) + try: + resp = invite_api_client.create_invite(current_user.id, service_id, email_address, permissions) + flash('Invite sent to {}'.format(resp['email_address']), 'default_with_tick') + return redirect(url_for('.manage_users', service_id=service_id)) + + except HTTPError as e: + if e.status_code == 404: + abort(404) + else: + raise e return render_template( 'views/invite-user.html', @@ -94,3 +114,14 @@ def delete_user(service_id, user_id): service=get_service_by_id_or_404(service_id), service_id=service_id ) + + +def _get_permissions(form): + permissions = [] + if form.get('send_messages') and form['send_messages'] == 'yes': + permissions.append('send_messages') + if form.get('manage_service') and form['manage_service'] == 'yes': + permissions.append('manage_service') + if form.get('manage_api_keys') and form['manage_api_keys'] == 'yes': + permissions.append('manage_api_keys') + return ','.join(permissions) diff --git a/app/main/views/sms.py b/app/main/views/send.py similarity index 53% rename from app/main/views/sms.py rename to app/main/views/send.py index 2e3f69091..856403105 100644 --- a/app/main/views/sms.py +++ b/app/main/views/send.py @@ -1,9 +1,6 @@ import csv import io import uuid -import botocore - -from datetime import date from flask import ( request, @@ -17,7 +14,6 @@ from flask import ( ) from flask_login import login_required, current_user -from werkzeug import secure_filename from notifications_python_client.errors import HTTPError from utils.template import Template, NeededByTemplateError, NoPlaceholderForDataError @@ -28,15 +24,30 @@ from app.main.uploader import ( s3download ) from app.main.dao import templates_dao +from app.main.dao import services_dao from app import job_api_client -from app.utils import ( - validate_phone_number, - InvalidPhoneError -) +from app.utils import validate_recipient, InvalidPhoneError, InvalidEmailError + +page_headings = { + 'email': 'Send emails', + 'sms': 'Send text messages' +} -@main.route("/services//sms/send", methods=['GET']) -def choose_sms_template(service_id): +@main.route("/services//send/letters", methods=['GET']) +def letters_stub(service_id): + return render_template( + 'views/letters.html', service_id=service_id + ) + + +@main.route("/services//send/", methods=['GET']) +def choose_template(service_id, template_type): + + service = services_dao.get_service_by_id_or_404(service_id) + + if template_type not in ['email', 'sms']: + abort(404) try: jobs = job_api_client.get_job(service_id)['data'] except HTTPError as e: @@ -45,69 +56,83 @@ def choose_sms_template(service_id): else: raise e return render_template( - 'views/choose-sms-template.html', + 'views/choose-template.html', templates=[ - Template(template) for template in templates_dao.get_service_templates(service_id)['data'] + Template( + template, + prefix=service['name'] + ) for template in templates_dao.get_service_templates(service_id)['data'] + if template['template_type'] == template_type ], + template_type=template_type, + page_heading=page_headings[template_type], + service=service, has_jobs=len(jobs), service_id=service_id ) -@main.route("/services//sms/send/", methods=['GET', 'POST']) +@main.route("/services//send/", methods=['GET', 'POST']) @login_required -def send_sms(service_id, template_id): +def send_messages(service_id, template_id): form = CsvUploadForm() if form.validate_on_submit(): try: - csv_file = form.file.data + csv_file = form.file filedata = _get_filedata(csv_file) upload_id = str(uuid.uuid4()) s3upload(upload_id, service_id, filedata, current_app.config['AWS_REGION']) session['upload_data'] = {"template_id": template_id, "original_file_name": filedata['file_name']} - return redirect(url_for('.check_sms', + return redirect(url_for('.check_messages', service_id=service_id, upload_id=upload_id)) except ValueError as e: - flash('There was a problem uploading: {}'.format(csv_file.filename)) + flash('There was a problem uploading: {}'.format(csv_file.data.filename)) flash(str(e)) - return redirect(url_for('.send_sms', service_id=service_id, template_id=template_id)) + return redirect(url_for('.send_messages', service_id=service_id, template_id=template_id)) + service = services_dao.get_service_by_id_or_404(service_id) template = Template( - templates_dao.get_service_template_or_404(service_id, template_id)['data'] + templates_dao.get_service_template_or_404(service_id, template_id)['data'], + prefix=service['name'] ) return render_template( - 'views/send-sms.html', + 'views/send.html', template=template, - column_headers=['phone'] + template.placeholders_as_markup, + column_headers=['to'] + template.placeholders_as_markup, form=form, + service=service, service_id=service_id ) -@main.route("/services//sms/send/.csv", methods=['GET']) +@main.route("/services//send/.csv", methods=['GET']) @login_required def get_example_csv(service_id, template_id): template = templates_dao.get_service_template_or_404(service_id, template_id)['data'] placeholders = list(Template(template).placeholders) output = io.StringIO() writer = csv.writer(output) - writer.writerow(['phone'] + placeholders) - writer.writerow([current_user.mobile_number] + ["test {}".format(header) for header in placeholders]) - - return(output.getvalue(), 200, {'Content-Type': 'text/csv; charset=utf-8'}) + writer.writerow(['to'] + placeholders) + writer.writerow([ + { + 'email': current_user.email_address, + 'sms': current_user.mobile_number + }[template['template_type']] + ] + ["test {}".format(header) for header in placeholders]) + return output.getvalue(), 200, {'Content-Type': 'text/csv; charset=utf-8'} -@main.route("/services//sms/send//to-self", methods=['GET']) +@main.route("/services//send//to-self", methods=['GET']) @login_required -def send_sms_to_self(service_id, template_id): +def send_message_to_self(service_id, template_id): template = templates_dao.get_service_template_or_404(service_id, template_id)['data'] placeholders = list(Template(template).placeholders) output = io.StringIO() writer = csv.writer(output) - writer.writerow(['phone'] + placeholders) + writer.writerow(['to'] + placeholders) writer.writerow([current_user.mobile_number] + ["test {}".format(header) for header in placeholders]) filedata = { 'file_name': 'Test run', @@ -117,45 +142,46 @@ def send_sms_to_self(service_id, template_id): s3upload(upload_id, service_id, filedata, current_app.config['AWS_REGION']) session['upload_data'] = {"template_id": template_id, "original_file_name": filedata['file_name']} - return redirect(url_for('.check_sms', + return redirect(url_for('.check_messages', service_id=service_id, upload_id=upload_id)) -@main.route("/services//sms/check/", +@main.route("/services//check/", methods=['GET', 'POST']) @login_required -def check_sms(service_id, upload_id): +def check_messages(service_id, upload_id): + + upload_data = session['upload_data'] + template_id = upload_data.get('template_id') + service = services_dao.get_service_by_id_or_404(service_id) if request.method == 'GET': contents = s3download(service_id, upload_id) if not contents: flash('There was a problem reading your upload file') - upload_data = session['upload_data'] - template_id = upload_data.get('template_id') raw_template = templates_dao.get_service_template_or_404(service_id, template_id)['data'] upload_result = _get_rows(contents, raw_template) session['upload_data']['notification_count'] = len(upload_result['rows']) template = Template( raw_template, values=upload_result['rows'][0] if upload_result['valid'] else {}, - drop_values={'phone'} + drop_values={'to'}, + prefix=service['name'] ) return render_template( - 'views/check-sms.html', + 'views/check.html', upload_result=upload_result, template=template, - column_headers=['phone number'] + list( - template.placeholders if upload_result['valid'] else template.placeholders_as_markup - ), + page_heading=page_headings[template.template_type], + column_headers=['to'] + list(template.placeholders_as_markup), original_file_name=upload_data.get('original_file_name'), service_id=service_id, + service=service, form=CsvUploadForm() ) elif request.method == 'POST': - upload_data = session['upload_data'] original_file_name = upload_data.get('original_file_name') - template_id = upload_data.get('template_id') notification_count = upload_data.get('notification_count') session.pop('upload_data') try: @@ -167,23 +193,40 @@ def check_sms(service_id, upload_id): raise e flash('We’ve started sending your messages', 'default_with_tick') - return redirect(url_for('main.view_job', - service_id=service_id, - job_id=upload_id)) + return redirect( + url_for('main.view_job', service_id=service_id, job_id=upload_id) + ) def _get_filedata(file): - lines = file.read().decode('utf-8').splitlines() - if len(lines) < 2: # must be at least header and one line - message = 'The file {} contained no data'.format(file.filename) + import itertools + reader = csv.reader( + file.data.getvalue().decode('utf-8').splitlines(), + quoting=csv.QUOTE_NONE, + skipinitialspace=True + ) + lines = [] + for row in reader: + non_empties = itertools.dropwhile(lambda x: x.strip() == '', row) + has_content = [] + for item in non_empties: + has_content.append(item) + if has_content: + lines.append(row) + + if len(lines) < 2: # must be header row and at least one data row + message = 'The file {} contained no data'.format(file.data.filename) raise ValueError(message) - return {'file_name': file.filename, 'data': lines} + + content_lines = [] + for row in lines: + content_lines.append(','.join(row).rstrip(',')) + return {'file_name': file.data.filename, 'data': content_lines} def _get_rows(contents, raw_template): reader = csv.DictReader( contents.split('\n'), - lineterminator='\n', quoting=csv.QUOTE_NONE, skipinitialspace=True ) @@ -192,8 +235,11 @@ def _get_rows(contents, raw_template): for row in reader: rows.append(row) try: - validate_phone_number(row['phone']) - Template(raw_template, values=row, drop_values={'phone'}).replaced - except (InvalidPhoneError, NeededByTemplateError, NoPlaceholderForDataError): + validate_recipient( + row.get('to', ''), + template_type=raw_template['template_type'] + ) + Template(raw_template, values=row, drop_values={'to'}).replaced + except (InvalidEmailError, InvalidPhoneError, NeededByTemplateError, NoPlaceholderForDataError): valid = False return {"valid": valid, "rows": rows} diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index fc27a366e..c2931b1cd 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -7,10 +7,10 @@ from flask import ( flash ) -from flask.ext.login import current_user +from flask.ext.login import (current_user, login_fresh, confirm_login) from app.main import main -from app.main.dao import users_dao +from app.main.dao import (users_dao, services_dao) from app.main.forms import LoginForm @@ -18,11 +18,24 @@ from app.main.forms import LoginForm def sign_in(): if current_user and current_user.is_authenticated(): return redirect(url_for('main.choose_service')) + form = LoginForm() if form.validate_on_submit(): user = users_dao.get_user_by_email(form.email_address.data) user = _get_and_verify_user(user, form.password.data) if user: + # Remember me login + if not login_fresh() and \ + not current_user.is_anonymous() and \ + current_user.id == user.id and \ + user.is_active(): + confirm_login() + services = services_dao.get_services(user.id).get('data', []) + if (len(services) == 1): + return redirect(url_for('main.service_dashboard', service_id=services[0]['id'])) + else: + return redirect(url_for('main.choose_service')) + session['user_details'] = {"email": user.email_address, "id": user.id} if user.state == 'pending': return redirect(url_for('.verify')) diff --git a/app/main/views/sign_out.py b/app/main/views/sign_out.py index de0ca58e1..cbe2738e3 100644 --- a/app/main/views/sign_out.py +++ b/app/main/views/sign_out.py @@ -6,8 +6,7 @@ from app.main import main @main.route('/sign-out', methods=(['GET'])) -@login_required def sign_out(): session.clear() logout_user() - return redirect(url_for('main.index')) + return redirect(url_for('main.sign_in')) diff --git a/app/main/views/templates.py b/app/main/views/templates.py index be1001113..15f72b113 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -5,45 +5,48 @@ from notifications_python_client.errors import HTTPError from utils.template import Template from app.main import main -from app.main.forms import TemplateForm +from app.main.forms import SMSTemplateForm, EmailTemplateForm from app import job_api_client -from app.main.dao.services_dao import get_service_by_id +from app.main.dao.services_dao import get_service_by_id_or_404 from app.main.dao import templates_dao as tdao from app.main.dao import services_dao as sdao -@main.route("/services//templates") +form_objects = { + 'email': EmailTemplateForm, + 'sms': SMSTemplateForm +} + + +@main.route("/services//templates/add-", methods=['GET', 'POST']) @login_required -def manage_service_templates(service_id): - return redirect(url_for( - '.choose_sms_template', - service_id=service_id - )) +def add_service_template(service_id, template_type): + service = sdao.get_service_by_id_or_404(service_id) -@main.route("/services//templates/add", methods=['GET', 'POST']) -@login_required -def add_service_template(service_id): - try: - service = sdao.get_service_by_id(service_id)['data'] - except HTTPError as e: - if e.status_code == 404: - abort(404) - else: - raise e + if template_type not in ['sms', 'email']: + abort(404) - form = TemplateForm() + form = form_objects[template_type]() if form.validate_on_submit(): tdao.insert_service_template( - form.name.data, form.template_content.data, service_id) - return redirect(url_for( - '.choose_sms_template', service_id=service_id)) + form.name.data, + template_type, + form.template_content.data, + service_id, + form.subject.data if hasattr(form, 'subject') else None + ) + return redirect( + url_for('.choose_template', service_id=service_id, template_type=template_type) + ) + return render_template( - 'views/edit-template.html', - h1='Add a text message template', + 'views/edit-{}-template.html'.format(template_type), form=form, - service_id=service_id) + template_type=template_type, + service_id=service_id + ) @main.route("/services//templates/", methods=['GET', 'POST']) @@ -51,20 +54,26 @@ def add_service_template(service_id): def edit_service_template(service_id, template_id): template = tdao.get_service_template_or_404(service_id, template_id)['data'] template['template_content'] = template['content'] - form = TemplateForm(**template) + form = form_objects[template['template_type']](**template) if form.validate_on_submit(): tdao.update_service_template( - template_id, form.name.data, - form.template_content.data, service_id) - return redirect(url_for('.choose_sms_template', service_id=service_id)) + template_id, form.name.data, template['template_type'], + form.template_content.data, service_id + ) + return redirect(url_for( + '.choose_template', + service_id=service_id, + template_type=template['template_type'] + )) return render_template( - 'views/edit-template.html', - h1='Edit template', + 'views/edit-{}-template.html'.format(template['template_type']), form=form, service_id=service_id, - template_id=template_id) + template_id=template_id, + template_type=template['template_type'] + ) @main.route("/services//templates//delete", methods=['GET', 'POST']) @@ -74,13 +83,17 @@ def delete_service_template(service_id, template_id): if request.method == 'POST': tdao.delete_service_template(service_id, template_id) - return redirect(url_for('.manage_service_templates', service_id=service_id)) + return redirect(url_for( + '.choose_template', + service_id=service_id, + template_type=template['template_type'] + )) template['template_content'] = template['content'] - form = TemplateForm(**template) + form = form_objects[template['template_type']](**template) flash('Are you sure you want to delete ‘{}’?'.format(form.name.data), 'delete') return render_template( - 'views/edit-template.html', + 'views/edit-{}-template.html'.format(template['template_type']), h1='Edit template', form=form, service_id=service_id, diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index 5a7af0112..325225ae2 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -12,7 +12,10 @@ from app.main.forms import TwoFactorForm @main.route('/two-factor', methods=['GET', 'POST']) def two_factor(): # TODO handle user_email not in session - user_id = session['user_details']['id'] + try: + user_id = session['user_details']['id'] + except KeyError: + return redirect('main.sign_in') def _check_code(code): return users_dao.check_verify_code(user_id, code, "sms") @@ -27,7 +30,7 @@ def two_factor(): if 'password' in session['user_details']: user.set_password(session['user_details']['password']) users_dao.update_user(user) - login_user(user) + login_user(user, remember=form.remember_me.data if form.remember_me.data else False) finally: del session['user_details'] if (len(services) == 1): diff --git a/app/notify_client/api_client.py b/app/notify_client/api_client.py index f5f72298a..6d2a2016a 100644 --- a/app/notify_client/api_client.py +++ b/app/notify_client/api_client.py @@ -70,7 +70,7 @@ class NotificationsAdminAPIClient(NotificationsAPIClient): endpoint = "/service/{0}".format(service_id) return self.put(endpoint, data) - def create_service_template(self, name, type_, content, service_id): + def create_service_template(self, name, type_, content, service_id, subject=None): """ Create a service template. """ @@ -80,10 +80,14 @@ class NotificationsAdminAPIClient(NotificationsAPIClient): "content": content, "service": service_id } + if subject: + data.update({ + 'subject': subject + }) endpoint = "/service/{0}/template".format(service_id) return self.post(endpoint, data) - def update_service_template(self, id_, name, type_, content, service_id): + def update_service_template(self, id_, name, type_, content, service_id, subject=None): """ Update a service template. """ @@ -94,8 +98,12 @@ class NotificationsAdminAPIClient(NotificationsAPIClient): 'content': content, 'service': service_id } + if subject: + data.update({ + 'subject': subject + }) endpoint = "/service/{0}/template/{1}".format(service_id, id_) - return self.put(endpoint, data) + return self.post(endpoint, data) def get_service_template(self, service_id, template_id, *params): """ diff --git a/app/notify_client/invite_api_client.py b/app/notify_client/invite_api_client.py new file mode 100644 index 000000000..515422780 --- /dev/null +++ b/app/notify_client/invite_api_client.py @@ -0,0 +1,29 @@ + +from notifications_python_client.base import BaseAPIClient + + +class InviteApiClient(BaseAPIClient): + def __init__(self, base_url=None, client_id=None, secret=None): + super(self.__class__, self).__init__(base_url=base_url or 'base_url', + client_id=client_id or 'client_id', + secret=secret or 'secret') + + def init_app(self, app): + self.base_url = app.config['API_HOST_NAME'] + self.client_id = app.config['ADMIN_CLIENT_USER_NAME'] + self.secret = app.config['ADMIN_CLIENT_SECRET'] + + def create_invite(self, invite_from_id, service_id, email_address, permissions): + data = { + 'service': str(service_id), + 'email_address': email_address, + 'from_user': invite_from_id, + 'permissions': permissions + } + resp = self.post(url='/service/{}/invite'.format(service_id), data=data) + return resp['data'] + + def get_invites_for_service(self, service_id): + endpoint = '/service/{}/invite'.format(service_id) + resp = self.get(endpoint) + return resp['data'] diff --git a/app/notify_client/job_api_client.py b/app/notify_client/job_api_client.py index e39b19a23..b24643c8c 100644 --- a/app/notify_client/job_api_client.py +++ b/app/notify_client/job_api_client.py @@ -1,4 +1,3 @@ -import uuid from notifications_python_client.base import BaseAPIClient @@ -23,7 +22,6 @@ class JobApiClient(BaseAPIClient): def create_job(self, job_id, service_id, template_id, original_file_name, notification_count): data = { "id": job_id, - "service": service_id, "template": template_id, "original_file_name": original_file_name, "bucket_name": "service-{}-notify".format(service_id), diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index dafd4f66b..e0d1057d1 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -1,7 +1,7 @@ from notifications_python_client.notifications import BaseAPIClient from notifications_python_client.errors import HTTPError -from flask.ext.login import UserMixin +from flask.ext.login import (UserMixin, login_fresh) class UserApiClient(BaseAPIClient): @@ -81,6 +81,11 @@ class UserApiClient(BaseAPIClient): return False, 'Code not found' raise e + def get_users_for_service(self, service_id): + endpoint = '/service/{}/users'.format(service_id) + resp = self.get(endpoint) + return resp['data'] + class User(UserMixin): def __init__(self, fields, max_failed_login_count=3): @@ -100,6 +105,12 @@ class User(UserMixin): def is_active(self): return self.state == 'active' + def is_authenticated(self): + # To handle remember me token renewal + if not login_fresh(): + return False + return super(User, self).is_authenticated() + @property def id(self): return self._id diff --git a/app/templates/components/checkbox.html b/app/templates/components/checkbox.html new file mode 100644 index 000000000..e816f8aeb --- /dev/null +++ b/app/templates/components/checkbox.html @@ -0,0 +1,30 @@ +{% macro checkbox( + field, + hint=False, + help_link=None, + help_link_text=None, + width='2-3', + suffix=None +) %} + +{% endmacro %} \ No newline at end of file diff --git a/app/templates/components/email-message.html b/app/templates/components/email-message.html index b619b16fa..4b0047651 100644 --- a/app/templates/components/email-message.html +++ b/app/templates/components/email-message.html @@ -9,29 +9,27 @@ {% endif %}