Merge with master.

This commit is contained in:
Nicholas Staples
2016-01-19 09:49:01 +00:00
48 changed files with 686 additions and 327 deletions

View File

@@ -28,17 +28,17 @@ Node's package management tool.
brew install node
```
n is a tool for managing different versions of node. The following installs n and uses the latest version of node.
n is a tool for managing different versions of node. The following installs n
and uses the latest version of node.
```shell
npm install -g n
n latest
npm rebuild node-sass
```
Most of the frontend dependencies are managed using Git Submodules. Some are
managed with NPM and Bower. To install or update *all the things*, run
The frontend dependencies are managed using NPM and Bower. To install or update
*all the things*, run
```shell
git submodule init
git submodule update
npm install
npm run build
```

View File

@@ -1,7 +1,7 @@
import os
import re
from flask import Flask, session, Markup, render_template
from flask import Flask, session, Markup, escape, render_template
from flask._compat import string_types
from flask.ext.sqlalchemy import SQLAlchemy
from flask_login import LoginManager
@@ -47,6 +47,7 @@ def create_app(config_name, config_overrides=None):
application.add_template_filter(placeholders)
application.add_template_filter(replace_placeholders)
application.add_template_filter(nl2br)
application.after_request(useful_headers_after_request)
register_errorhandlers(application)
@@ -108,6 +109,14 @@ def placeholders(value):
))
def nl2br(value):
_paragraph_re = re.compile(r'(?:\r\n|\r|\n){2,}')
result = u'\n\n'.join(u'<p>%s</p>' % p.replace('\n', Markup('<br>\n'))
for p in _paragraph_re.split(escape(value)))
return Markup(result)
def replace_placeholders(template, values):
if not template:
return template

View File

@@ -0,0 +1,58 @@
(function(Modules) {
"use strict";
Modules.ApiKey = function() {
const states = {
'initial': `
<input type='button' class='api-key-button-show' value='Show API key' />
`,
'keyVisibleBasic': key => `
<span class="api-key-key">${key}</span>
`,
'keyVisible': key => `
<span class="api-key-key">${key}</span>
<input type='button' class='api-key-button-copy' value='Copy API key to clipboard' />
`,
'keyCopied': `
<span class="api-key-key">Copied to clipboard</span>
<input type='button' class='api-key-button-show' value='Show API key' />
`
};
this.copyKey = function(keyElement, callback) {
var selection = window.getSelection ? window.getSelection() : document.selection,
range = document.createRange();
selection.removeAllRanges();
range.selectNodeContents(keyElement);
selection.addRange(range);
document.execCommand('copy');
selection.removeAllRanges();
callback();
};
this.start = function(component) {
const $component = $(component).html(states.initial).attr('aria-live', 'polite'),
key = $component.data('key');
$component
.on(
'click', '.api-key-button-show', () =>
$component.html(
document.queryCommandSupported('copy') ?
states.keyVisible(key) : states.keyVisibleBasic(key)
)
)
.on(
'click', '.api-key-button-copy', () =>
this.copyKey(
$('.api-key-key', component)[0], () =>
$component.html(states.keyCopied)
)
);
};
};
})(window.GOVUK.Modules);

View File

@@ -1 +1,3 @@
$(() => GOVUK.modules.start());
$(() => new GOVUK.SelectionButtons('.block-label input'));

View File

@@ -5,3 +5,7 @@
.column-three-quarters {
@include grid-column(3/4);
}
.bottom-gutter {
margin-bottom: $gutter;
}

View File

@@ -0,0 +1,17 @@
.api-key {
&-key {
font-family: monospace;
display: block;
margin-bottom: 10px;
}
&-button-show {
@include button($grey-3);
}
&-button-copy {
@include button($grey-3);
}
}

View File

@@ -27,3 +27,20 @@
}
}
.banner-dangerous {
@extend .banner;
background: $white;
color: $error-colour;
border: 5px solid $error-colour;
margin: 15px 0;
@include bold-19;
text-align: left;
.button {
@include button($error-colour);
margin-top: 10px;
}
}

View File

@@ -0,0 +1,24 @@
.email-message {
margin-bottom: $gutter;
border: 1px solid $border-colour;
&-subject {
border-bottom: 1px solid $border-colour;;
padding: 10px;
@include bold-19;
}
&-body {
border-bottom: 1px solid white;
padding: 10px;
overflow: hidden;
max-height: 103px;
}
&-name {
@include bold-19;
margin: 50px 0 10px 0;
}
}

View File

@@ -7,6 +7,26 @@
margin-top: $gutter;
}
&-delete-link {
line-height: 40px;
padding: 0 0 0 5px;
a:visited,
a:link {
color: $error-colour;
display: inline-block;
vertical-align: center;
}
a:hover,
a:active {
color: $mellow-red;
}
}
.button {}
.button-destructive {

View File

@@ -45,4 +45,9 @@
margin: -$gutter-half 0 $gutter 0;
}
&-name {
@include bold-19;
margin: 50px 0 10px 0;
}
}

View File

@@ -1,33 +1,33 @@
// Dependencies from GOV.UK Frontend Toolkit
// https://github.com/alphagov/govuk_frontend_toolkit/
@import '../govuk_frontend_toolkit/stylesheets/conditionals';
@import '../govuk_frontend_toolkit/stylesheets/shims';
@import '../govuk_frontend_toolkit/stylesheets/measurements';
@import '../govuk_frontend_toolkit/stylesheets/css3';
@import '../govuk_frontend_toolkit/stylesheets/colours';
@import '../govuk_frontend_toolkit/stylesheets/typography';
@import '../govuk_frontend_toolkit/stylesheets/grid_layout';
@import '../govuk_frontend_toolkit/stylesheets/helpers';
@import '../govuk_frontend_toolkit/stylesheets/url-helpers';
@import '../govuk_frontend_toolkit/stylesheets/design-patterns/buttons';
@import '../govuk_frontend_toolkit/stylesheets/design-patterns/alpha-beta';
@import 'conditionals';
@import 'shims';
@import 'measurements';
@import 'css3';
@import 'colours';
@import 'typography';
@import 'grid_layout';
@import 'helpers';
@import 'url-helpers';
@import 'design-patterns/buttons';
@import 'design-patterns/alpha-beta';
// Dependencies from GOV.UK Elements
// https://github.com/alphagov/govuk_elements
@import '../govuk_elements/public/sass/elements/helpers';
@import '../govuk_elements/public/sass/elements/reset';
@import '../govuk_elements/public/sass/elements/buttons';
@import '../govuk_elements/public/sass/elements/details';
@import '../govuk_elements/public/sass/elements/elements-typography';
@import '../govuk_elements/public/sass/elements/forms';
@import '../govuk_elements/public/sass/elements/forms/form-validation';
@import '../govuk_elements/public/sass/elements/forms/form-block-labels';
@import '../govuk_elements/public/sass/elements/forms/form-validation';
@import '../govuk_elements/public/sass/elements/icons';
@import '../govuk_elements/public/sass/elements/layout';
@import '../govuk_elements/public/sass/elements/lists';
@import '../govuk_elements/public/sass/elements/panels';
@import '../govuk_elements/public/sass/elements/tables';
@import 'elements/helpers';
@import 'elements/reset';
@import 'elements/buttons';
@import 'elements/details';
@import 'elements/elements-typography';
@import 'elements/forms';
@import 'elements/forms/form-validation';
@import 'elements/forms/form-block-labels';
@import 'elements/forms/form-validation';
@import 'elements/icons';
@import 'elements/layout';
@import 'elements/lists';
@import 'elements/panels';
@import 'elements/tables';
// Specific to this application
@@ -44,6 +44,8 @@
@import 'components/browse-list';
@import 'components/management-navigation';
@import 'components/dropdown';
@import 'components/email-message';
@import 'components/api-key';
@import 'views/job';

View File

@@ -5,5 +5,5 @@ main = Blueprint('main', __name__)
from app.main.views import (
index, sign_in, sign_out, register, two_factor, verify, sms, add_service,
code_not_received, jobs, dashboard, templates, service_settings, forgot_password,
new_password, styleguide, user_profile, choose_service
new_password, styleguide, user_profile, choose_service, api_keys
)

View File

@@ -166,8 +166,12 @@ class AddServiceForm(Form):
self._names_func = names_func
super(AddServiceForm, self).__init__(*args, **kwargs)
name = StringField('Service Name', validators=[
DataRequired(message='Service name can not be empty')])
name = StringField(
'Service name',
validators=[
DataRequired(message='Service name can not be empty')
]
)
def validate_name(self, a):
if a.data in self._names_func():

View File

@@ -1,15 +1,22 @@
import os
import uuid
from boto3 import resource
# TODO add service name to bucket name as well
def s3upload(filepath):
filename = filepath.split(os.path.sep)[-1]
def s3upload(service_id, filedata):
upload_id = str(uuid.uuid4())
s3 = resource('s3')
s3.create_bucket(Bucket=upload_id)
key = s3.Object(upload_id, filename)
key.put(Body=open(filepath, 'rb'), ServerSideEncryption='AES256')
bucket_name = 'service-{}-notify'.format(service_id)
s3.create_bucket(Bucket=bucket_name)
contents = '\n'.join(filedata['data'])
key = s3.Object(bucket_name, upload_id)
key.put(Body=contents, ServerSideEncryption='AES256')
return upload_id
def s3download(service_id, upload_id):
s3 = resource('s3')
bucket_name = 'service-{}-notify'.format(service_id)
key = s3.Object(bucket_name, upload_id)
contents = key.get()['Body'].read().decode('utf-8')
return contents

View File

@@ -0,0 +1,39 @@
templates = [
{
'type': 'sms',
'name': 'Confirmation',
'body': 'Lasting power of attorney: Weve received your application. Applications take between 8 and 10 weeks to process.' # noqa
},
{
'type': 'sms',
'name': 'Reminder',
'body': 'Vehicle tax: Your vehicle tax for ((registration number)) expires on ((date)). Tax your vehicle at www.gov.uk/vehicle-tax' # noqa
},
{
'type': 'sms',
'name': 'Warning',
'body': 'Vehicle tax: Your vehicle tax for ((registration number)) has expired. Tax your vehicle at www.gov.uk/vehicle-tax' # noqa
},
{
'type': 'email',
'name': 'Application alert 06/2016',
'subject': 'Your lasting power of attorney application',
'body': """Dear ((name)),
When youve made your lasting power of attorney (LPA), you need to register it \
with the Office of the Public Guardian (OPG).
You can apply to register your LPA yourself if youre able to make your own decisions.
Your attorney can also register it for you. Youll be told if they do and you can \
object to the registration.
It takes between 8 and 10 weeks to register an LPA if there are no mistakes in the application.
"""
},
{
'type': 'sms',
'name': 'Air quality alert',
'body': 'Air pollution levels will be ((level)) in ((region)) tomorrow.'
},
]

View File

@@ -1,4 +1,4 @@
from flask import render_template, jsonify, redirect, session, url_for
from flask import request, render_template, jsonify, redirect, session, url_for, abort
from flask_login import login_required
from app.main import main
from app.main.dao import services_dao, users_dao
@@ -8,11 +8,19 @@ from app.main.forms import AddServiceForm
@main.route("/add-service", methods=['GET', 'POST'])
@login_required
def add_service():
# TODO fix up this
form = AddServiceForm(services_dao.find_all_service_names)
services = services_dao.get_services()
if len(services) > 0:
heading = 'Set up notifications for your service'
else:
heading = 'Add a new service'
if form.validate_on_submit():
user = users_dao.get_user_by_id(session['user_id'])
service_id = services_dao.insert_new_service(form.name.data, user)
return redirect(url_for('main.service_dashboard', service_id=service_id))
else:
return render_template('views/add-service.html', form=form)
return render_template(
'views/add-service.html',
form=form,
heading=heading
)

View File

@@ -0,0 +1,9 @@
from flask import render_template
from flask_login import login_required
from app.main import main
@main.route("/services/<int:service_id>/api-keys")
@login_required
def api_keys(service_id):
return render_template('views/api-keys.html', service_id=service_id)

View File

@@ -36,9 +36,3 @@ def check_email(service_id):
@login_required
def manage_users(service_id):
return render_template('views/manage-users.html', service_id=service_id)
@main.route("/services/<int:service_id>/api-keys")
@login_required
def apikeys(service_id):
return render_template('views/api-keys.html', service_id=service_id)

View File

@@ -20,24 +20,15 @@ from werkzeug import secure_filename
from app.main import main
from app.main.forms import CsvUploadForm
from app.main.uploader import s3upload
from app.main.uploader import (
s3upload,
s3download
)
# TODO move this to the templates directory
message_templates = [
{
'name': 'Reminder',
'body': """
Vehicle tax: Your vehicle tax for ((registration number)) expires on ((date)).
Tax your vehicle at www.gov.uk/vehicle-tax
"""
},
{
'name': 'Warning',
'body': """
Vehicle tax: Your vehicle tax for ((registration number)) has expired.
Tax your vehicle at www.gov.uk/vehicle-tax
"""
},
from ._templates import templates
sms_templates = [
template for template in templates if template['type'] == 'sms'
]
@@ -48,68 +39,52 @@ def send_sms(service_id):
if form.validate_on_submit():
try:
csv_file = form.file.data
filename = _format_filename(csv_file.filename)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'],
filename)
csv_file.save(filepath)
_check_file(csv_file.filename, filepath)
filedata = _get_filedata(csv_file)
upload_id = s3upload(service_id, filedata)
return redirect(url_for('.check_sms',
service_id=service_id,
recipients=filename))
except (IOError, ValueError) as e:
upload_id=upload_id))
except ValueError as e:
message = 'There was a problem uploading: {}'.format(
csv_file.filename)
flash(message)
if isinstance(e, ValueError):
flash(str(e))
os.remove(filepath)
flash(str(e))
return redirect(url_for('.send_sms', service_id=service_id))
return render_template('views/send-sms.html',
message_templates=message_templates,
message_templates=sms_templates,
form=form,
service_id=service_id)
@main.route("/services/<int:service_id>/sms/check", methods=['GET', 'POST'])
@main.route("/services/<int:service_id>/sms/check/<upload_id>",
methods=['GET', 'POST'])
@login_required
def check_sms(service_id):
def check_sms(service_id, upload_id):
if request.method == 'GET':
filename = request.args.get('recipients')
if not filename:
abort(400)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'],
filename)
upload_result = _build_upload_result(filepath)
if upload_result.get('rejects'):
flash('There was a problem with some of the numbers')
contents = s3download(service_id, upload_id)
upload_result = _get_numbers(contents)
# TODO get original file name
return render_template(
'views/check-sms.html',
upload_result=upload_result,
filename=filename,
message_template=message_templates[0]['body'],
filename='someupload_file_name.csv',
message_template=sms_templates[0]['body'],
service_id=service_id
)
elif request.method == 'POST':
filename = request.form['recipients']
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'],
filename)
try:
upload_id = s3upload(filepath)
# TODO when job is created record filename in job itself
# so downstream pages can get the original filename that way
session[upload_id] = filename
return redirect(url_for('main.view_job', service_id=service_id, job_id=upload_id))
except:
flash('There as a problem saving the file')
return redirect(url_for('.check_sms', recipients=filename))
# TODO create the job with template, file location etc.
return redirect(url_for('main.view_job',
service_id=service_id,
job_id=upload_id))
def _check_file(filename, filepath):
if os.stat(filepath).st_size == 0:
message = 'The file {} contained no data'.format(filename)
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)
raise ValueError(message)
return {'filename': file.filename, 'data': lines}
def _format_filename(filename):
@@ -119,24 +94,16 @@ def _format_filename(filename):
return secure_filename(formatted_name)
def _open(file):
return open(file, 'r')
def _build_upload_result(csv_file):
try:
file = _open(csv_file, 'r')
pattern = re.compile(r'^\+44\s?\d{4}\s?\d{6}$')
reader = csv.DictReader(
file.read().splitlines(),
lineterminator='\n',
quoting=csv.QUOTE_NONE)
valid, rejects = [], []
for i, row in enumerate(reader):
if pattern.match(row['phone']):
valid.append(row)
else:
rejects.append({"line_number": i+2, "phone": row['phone']})
return {"valid": valid, "rejects": rejects}
finally:
file.close()
def _get_numbers(contents):
pattern = re.compile(r'^\+44\s?\d{4}\s?\d{6}$') # need better validation
reader = csv.DictReader(
contents.split('\n'),
lineterminator='\n',
quoting=csv.QUOTE_NONE)
valid, rejects = [], []
for i, row in enumerate(reader):
if pattern.match(row['phone']):
valid.append(row)
else:
rejects.append({"line_number": i+2, "phone": row['phone']})
return {"valid": valid, "rejects": rejects}

View File

@@ -1,16 +1,19 @@
from flask import request, render_template, redirect, url_for
from flask import request, render_template, redirect, url_for, flash
from flask_login import login_required
from app.main import main
from app.main.forms import TemplateForm
from ._templates import templates
@main.route("/services/<int:service_id>/templates")
@login_required
def manage_templates(service_id):
return render_template(
'views/manage-templates.html',
service_id=service_id
service_id=service_id,
templates=templates,
)
@@ -31,21 +34,49 @@ def add_template(service_id):
return redirect(url_for('.manage_templates', service_id=service_id))
@main.route("/services/<int:service_id>/templates/<template_id>", methods=['GET', 'POST'])
@main.route("/services/<int:service_id>/templates/<int:template_id>", methods=['GET', 'POST'])
@login_required
def edit_template(service_id, template_id):
form = TemplateForm()
form.template_name.data = 'Reminder'
form.template_body.data = 'Vehicle tax: Your vehicle tax for ((registration number)) expires on ((date)). Tax your vehicle at www.gov.uk/vehicle-tax' # noqa
form.template_name.data = templates[template_id - 1]['name']
form.template_body.data = templates[template_id - 1]['body']
if request.method == 'GET':
return render_template(
'views/edit-template.html',
h1='Edit template',
form=form,
service_id=service_id
service_id=service_id,
template_id=template_id
)
elif request.method == 'POST':
return redirect(url_for('.manage_templates', service_id=service_id))
@main.route("/services/<int:service_id>/templates/<int:template_id>/delete", methods=['GET', 'POST'])
@login_required
def delete_template(service_id, template_id):
form = TemplateForm()
form.template_name.data = templates[template_id - 1]['name']
form.template_body.data = templates[template_id - 1]['body']
if request.method == 'GET':
flash('Are you sure you want to delete {}?'.format(form.template_name.data), 'delete')
return render_template(
'views/edit-template.html',
h1='Edit template',
form=form,
service_id=service_id,
template_id=template_id
)
elif request.method == 'POST':
if request.form.get('delete'):
return redirect(url_for('.manage_templates', service_id=service_id))
else:
return redirect(url_for('.manage_templates', service_id=service_id))

View File

@@ -20,5 +20,5 @@ def verify():
verify_codes_dao.use_code_for_user_and_type(user_id=user.id, code_type='sms')
users_dao.activate_user(user.id)
login_user(user)
return redirect(url_for('.add_service'))
return redirect(url_for('.add_service', first='first'))
return render_template('views/verify.html', form=form)

View File

@@ -14,7 +14,6 @@ def send_sms_code(user_id, mobile_number):
verify_codes_dao.add_code(user_id=user_id, code=sms_code, code_type='sms')
notifications_api_client.send_sms(mobile_number=mobile_number,
message=sms_code)
return sms_code

View File

@@ -56,7 +56,7 @@
Service name
</div>
<a href="#">Switch to A N Other service</a>
<a href="#">Add a new service to Notify</a>
<a href="{{ url_for('.add_service') }}">Add a new service to GOV.UK Notify</a>
</div>
</div>
<div class="column-half management-navigation-account">
@@ -68,16 +68,22 @@
{% endif %}
<main id="content" role="main" class="page-container">
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="error-summary">
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<ul class="banner-dangerous">
{% for category, message in messages %}
<li class="flash-message">
{{ message }}
{% if 'delete' == category %}
<form method='post'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" class="button" name="delete" value="Yes, delete this template" />
</form>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endif %}
{% endwith %}
{% block fullwidth_content %}{% endblock %}
</main>

View File

@@ -0,0 +1,5 @@
{% macro api_key(key) %}
<div data-module="api-key" data-key="{{ key }}">
<span class="api-key-key">{{ key }}</span>
</div>
{% endmacro %}

View File

@@ -0,0 +1,19 @@
{% macro email_message(subject, body, name=None, edit_link=None) %}
{% if name %}
<h3 class="email-message-name">
{% if edit_link %}
<a href="{{ edit_link }}">{{ name }}</a>
{% else %}
{{ name }}
{% endif %}
</h3>
{% endif %}
<div class="email-message">
<div class="email-message-subject">
{{ subject|placeholders }}
</div>
<div class="email-message-body">
{{ body|nl2br|placeholders }}
</div>
</div>
{% endmacro %}

View File

@@ -1,11 +1,23 @@
{% macro page_footer(button_text=None, back_link=False, back_link_text="Back", destructive=False) %}
{% macro page_footer(
button_text=None,
destructive=False,
back_link=False,
back_link_text="Back",
delete_link=False,
delete_link_text="delete"
) %}
<div class="page-footer">
{% if button_text %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" class="button{% if destructive %}-destructive{% endif %}" value="{{ button_text }}" />
{% endif %}
{% if delete_link %}
<span class="page-footer-delete-link">
or <a href="{{ delete_link }}">{{ delete_link_text }}</a>
</span>
{% endif %}
{% if back_link %}
<a class="page-footer-back-link" role="button" href="{{ back_link }}">{{ back_link_text }}</a>
<a class="page-footer-back-link" href="{{ back_link }}">{{ back_link_text }}</a>
{% endif %}
</div>
{% endmacro %}

View File

@@ -1,4 +1,13 @@
{% macro sms_message(body, recipient) %}
{% macro sms_message(body, recipient=None, name=None, edit_link=None) %}
{% if name %}
<h3 class="sms-message-name">
{% if edit_link %}
<a href="{{ edit_link }}">{{ name }}</a>
{% else %}
{{ name }}
{% endif %}
</h3>
{% endif %}
<div class="sms-message">
<div class="sms-message-wrapper">
{{ body|placeholders }}

View File

@@ -3,8 +3,12 @@
{% block fullwidth_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1>404</h1>
<p>Sorry, that page doesn't exist.</p>
<h1 class="heading-xlarge">
Page could not be found
</h1>
<p>
Check you've entered the correct web address.
</p>
</div>
</div>
{% endblock %}

View File

@@ -3,8 +3,10 @@
{% block fullwidth_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1>500</h1>
<p>Sorry, something went wrong on our system.</p>
<h1 class="heading-xlarge">
Sorry, we're experiencing technical difficulties
</h1>
<p>Try again later.</p>
</div>
</div>
{% endblock %}

View File

@@ -1,17 +1,18 @@
<nav class="navigation">
<ul>
<li><a href="{{ url_for('.service_dashboard', service_id=123) }}">Dashboard</a></li>
<li><a href="{{ url_for('.service_dashboard', service_id=service_id) }}">Dashboard</a></li>
</ul>
<ul>
<li><a href="{{ url_for('.send_sms', service_id=123) }}">Send text messages</a></li>
<li><a href="{{ url_for('.send_email', service_id=123) }}">Send emails</a></li>
<li><a href="{{ url_for('.view_jobs', service_id=123) }}">Activity</a></li>
<li><a href="{{ url_for('.send_sms', service_id=service_id) }}">Send text messages</a></li>
<li><a href="{{ url_for('.send_email', service_id=service_id) }}">Send emails</a></li>
<li><a href="{{ url_for('.view_jobs', service_id=service_id) }}">Activity</a></li>
<li><a href="{{ url_for('.manage_templates', service_id=service_id) }}">Templates</a></li>
</ul>
<ul>
<li><a href="{{ url_for('.apikeys', service_id=123) }}">API keys and documentation</a></li>
<li><a href="{{ url_for('.api_keys', service_id=service_id) }}">API keys and documentation</a></li>
</ul>
<ul>
<li><a href="{{ url_for('.manage_users', service_id=123) }}">Manage users</a></li>
<li><a href="{{ url_for('.service_settings', service_id=123) }}">Service settings</a></li>
<li><a href="{{ url_for('.manage_users', service_id=service_id) }}">Manage users</a></li>
<li><a href="{{ url_for('.service_settings', service_id=service_id) }}">Service settings</a></li>
</ul>
</nav>

View File

@@ -1,5 +1,6 @@
{% extends "admin_template.html" %}
{% from "components/textbox.html" import textbox %}
{% from "components/page-footer.html" import page_footer %}
{% block page_title %}
GOV.UK Notify | Set up service
@@ -8,24 +9,34 @@ GOV.UK Notify | Set up service
{% block fullwidth_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-xlarge">Set up notifications for your service</h1>
<div class="column-two-thirds">
<p>Users will see your service name:</p>
<ul class="list-bullet">
<li>at the start of every text message, eg 'Vehicle tax: we received your payment, thank you'</li>
<li>as your email sender name</li>
</ul>
<h1 class="heading-xlarge">
{{ heading }}
</h1>
<form autocomplete="off" method="post">
{{ textbox(form.name) }}
<p>
Users will see your service name:
</p>
<p>
<button class="button" href="dashboard" role="button">Continue</button>
</p>
</form>
</div>
</div>
<ul class="list-bullet bottom-gutter">
<li>
at the start of every text message, eg Vehicle tax: we received your
payment, thank you
</li>
<li>
as your email sender name
</li>
</ul>
<form autocomplete="off" method="post">
{{ textbox(form.name) }}
{{ page_footer(
'Continue'
) }}
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,15 +1,52 @@
{% extends "withnav_template.html" %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/api-key.html" import api_key %}
{% block page_title %}
GOV.UK Notify | API keys and documentation
GOV.UK Notify | API keys and documentation
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-xlarge">API keys and documentation</h1>
<div class="grid-row">
<div class="column-two-thirds">
<p>Here's where developers can access information about the API and access keys</p>
<h1 class="heading-xlarge">
API keys and documentation
</h1>
<h2 class="heading-medium">
How to integrate GOV.UK Notify into your service
</h2>
<p>
blah blah blah this is where we tell you how the API works
</p>
<h2 class="heading-medium">Repositories</h2>
<p>
<a href="https://github.com/alphagov/notifications-api">GOV.UK Notify API</a>
</p>
<p>
<a href="https://github.com/alphagov/notify-api-client">GOV.UK Notify Python client</a>
</p>
<h2 class="heading-medium">API key for [service name]</h2>
{{ api_key('d30512af92e1386d63b90e5973b49a10') }}
<h2 class="heading-medium">API endpoint</h2>
<p>
https://www.notify.works/api/endpoint
</p>
</div>
</div>
{{ page_footer(
back_link=url_for('.service_dashboard', service_id=service_id),

View File

@@ -59,8 +59,6 @@
back_link = url_for(".send_sms", service_id=service_id)
)}}
<input type='hidden' name='recipients' value='{{filename}}'>
</form>
{% endif %}
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends "withnav_template.html" %}
{% extends "admin_template.html" %}
{% from "components/browse-list.html" import browse_list %}
{% block page_title %}

View File

@@ -14,9 +14,10 @@ GOV.UK Notify | Edit template
{{ textbox(form.template_name) }}
{{ textbox(form.template_body, highlight_tags=True) }}
{{ page_footer(
'Save and continue',
back_link=url_for('.service_dashboard', service_id=service_id),
back_link_text='Back to manage templates'
'Save',
delete_link=url_for('.delete_template', service_id=service_id, template_id=template_id) if template_id or None,
back_link=url_for('.manage_templates', service_id=service_id),
back_link_text='Back to templates'
) }}
</form>

View File

@@ -1,4 +1,7 @@
{% extends "withnav_template.html" %}
{% from "components/sms-message.html" import sms_message %}
{% from "components/email-message.html" import email_message %}
{% from "components/browse-list.html" import browse_list %}
{% block page_title %}
GOV.UK Notify | Manage templates
@@ -6,18 +9,32 @@ GOV.UK Notify | Manage templates
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-xlarge">Manage templates</h1>
<h1 class="heading-xlarge">Templates</h1>
<p>Here's where you can view templates, choose to add one, or edit/delete one.</p>
<p>
<a href="{{ url_for('.add_template', service_id=service_id) }}">Create new template</a>
<p>
<a href="{{ url_for('.edit_template', service_id=service_id, template_id=1) }}">Here is my first template</a>
</p>
<p>
<a class="button" href="{{ url_for('.add_template', service_id=service_id) }}" role="button">Add a new message template</a>
</p>
{% for template in templates %}
{% if template.type == 'sms' %}
{{ sms_message(
template.body,
name=template.name,
edit_link=url_for('.edit_template', service_id=service_id, template_id=loop.index)
) }}
{% elif template.type == 'email' %}
{{ email_message(
template.subject,
template.body,
name=template.name,
edit_link=url_for('.edit_template', service_id=service_id, template_id=loop.index)
) }}
{% endif %}
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -4,45 +4,42 @@
{% from "components/textbox.html" import textbox %}
{% block page_title %}
GOV.UK Notify | Send text messages
GOV.UK Notify | Send text messages
{% endblock %}
{% block maincolumn_content %}
<form method="POST" enctype="multipart/form-data">
<form method="POST" enctype="multipart/form-data">
<h1 class="heading-xlarge">Send text messages</h1>
<h1 class="heading-xlarge">Send text messages</h1>
<h2 class="heading-medium">1. Choose text message template</h2>
{% for template in message_templates %}
<div class="template-picker-option">
<div class="template-picker-option-radio">
<label class="block-label" for="template-{{loop.index}}">
{{ template.name }}
<input type="radio" name="template" id="template-{{loop.index}}" value="{{ template.name }}" />
</label>
</div>
{{ sms_message(template.body) }}
</div>
{% endfor %}
<fieldset class='form-group'>
<legend class="heading-medium">1. Choose text message template</legend>
{% for template in message_templates %}
<label class="block-label" for="template-{{loop.index}}">
{{ template.name }}
<input type="radio" name="template" id="template-{{loop.index}}" value="{{ template.name }}" />
</label>
{% endfor %}
</fieldset>
<p>
or <a href="{{ url_for(".add_template", service_id=service_id) }}">create a new template</a>
</p>
<p>
or <a href="{{ url_for(".add_template", service_id=service_id) }}">create a new template</a>
</p>
<h2 class="heading-medium">2. Add recipients</h2>
<h2 class="heading-medium">2. Add recipients</h2>
<p>
Upload a CSV file to add your recipients details.
</p>
<p>
You can also <a href="#">download an example CSV</a>.
</p>
<p>
{{textbox(form.file)}}
</p>
<p>
Upload a CSV file to add your recipients details.
</p>
<p>
You can also <a href="#">download an example CSV</a>.
</p>
<p>
{{textbox(form.file)}}
</p>
{{ page_footer("Continue") }}
{{ page_footer("Continue") }}
</form>
</form>
{% endblock %}

View File

@@ -7,6 +7,7 @@
{% from "components/sms-message.html" import sms_message %}
{% from "components/table.html" import mapping_table, list_table, row, field %}
{% from "components/textbox.html" import textbox %}
{% from "components/api-key.html" import api_key %}
{% block page_title %}
Styleguide GOV.UK Notify
@@ -152,4 +153,8 @@
{{ textbox(form.password) }}
{{ textbox(form.message, highlight_tags=True) }}
<h2 class="heading-large">API key</h2>
{{ api_key('d30512af92e1386d63b90e5973b49a10') }}
{% endblock %}

View File

@@ -13,7 +13,8 @@ var gulp = require('gulp'),
paths = {
src: 'app/assets/',
dist: 'app/static/',
templates: 'app/templates/'
templates: 'app/templates/',
npm: 'node_modules/'
};
// 3. TASKS
@@ -31,25 +32,34 @@ gulp.task('copy:govuk_template:assets', () => gulp.src('bower_components/govuk_t
gulp.task('javascripts', () => gulp
.src([
paths.src + 'govuk_frontend_toolkit/javascripts/govuk/modules.js',
paths.src + 'javascripts/highlightTags.js',
paths.npm + 'govuk_frontend_toolkit/javascripts/govuk/modules.js',
paths.npm + 'govuk_frontend_toolkit/javascripts/govuk/selection-buttons.js',
paths.src + 'javascripts/apiKey.js',
paths.src + 'javascripts/dropdown.js',
paths.src + 'javascripts/highlightTags.js',
paths.src + 'javascripts/main.js'
])
.pipe(plugins.babel({
presets: ['es2015']
}))
.pipe(plugins.uglify())
.pipe(plugins.addSrc.prepend(
'./node_modules/jquery/dist/jquery.min.js'
))
.pipe(plugins.addSrc.prepend([
paths.npm + 'jquery/dist/jquery.min.js',
paths.npm + 'query-command-supported/dist/queryCommandSupported.min.js'
]))
.pipe(plugins.concat('all.js'))
.pipe(gulp.dest(paths.dist + 'javascripts/'))
);
gulp.task('sass', () => gulp
.src(paths.src + '/stylesheets/main*.scss')
.pipe(plugins.sass({outputStyle: 'compressed'}))
.pipe(plugins.sass({
outputStyle: 'compressed',
includePaths: [
paths.npm + 'govuk-elements-sass/public/sass/',
paths.npm + 'govuk_frontend_toolkit/stylesheets/'
]
}))
.pipe(gulp.dest(paths.dist + '/stylesheets'))
);
@@ -57,7 +67,10 @@ gulp.task('sass', () => gulp
// Copy images
gulp.task('images', () => gulp
.src(paths.src + 'images/**/*')
.src([
paths.src + 'images/**/*',
paths.npm + 'govuk_frontend_toolkit/images/**/*'
])
.pipe(gulp.dest(paths.dist + '/images'))
);

View File

@@ -22,6 +22,8 @@
"babel-core": "6.3.26",
"babel-preset-es2015": "6.3.13",
"bower": "1.7.1",
"govuk-elements-sass": "1.1.1",
"govuk_frontend_toolkit": "4.6.0",
"gulp": "3.9.0",
"gulp-add-src": "0.2.0",
"gulp-babel": "6.1.1",
@@ -31,6 +33,7 @@
"gulp-load-plugins": "1.1.0",
"gulp-sass": "2.1.1",
"gulp-uglify": "1.5.1",
"jquery": "1.11.2"
"jquery": "1.11.2",
"query-command-supported": "1.0.0"
}
}

View File

@@ -1,4 +1,6 @@
-r requirements.txt
pep8==1.5.7
pytest==2.8.1
pytest-mock==0.8.1
pytest-mock==0.8.1
moto==0.4.19
httpretty==0.8.10

View File

@@ -4,7 +4,12 @@ from tests import create_test_user
from app.models import User
def test_get_should_render_add_service_template(app_, db_, db_session, active_user, mock_get_service):
def test_get_should_render_add_service_template(app_,
db_,
db_session,
active_user,
mock_get_service,
mock_get_services):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
@@ -35,7 +40,8 @@ def test_should_return_form_errors_when_service_name_is_empty(app_,
db_,
db_session,
active_user,
mock_get_service):
mock_get_service,
mock_get_services):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)

View File

@@ -0,0 +1,13 @@
from flask import url_for
def test_should_show_api_keys_and_documentation_page(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.get(url_for('main.api_keys', service_id=123))
assert response.status_code == 200

View File

@@ -1,21 +1,15 @@
from io import BytesIO
from unittest import mock
from unittest.mock import mock_open
from flask import url_for
from tests import create_test_user
import moto
def test_upload_empty_csvfile_returns_to_upload_page(
app_, db_, db_session,
mocker):
_setup_mocker_for_empty_file(mocker)
def test_upload_empty_csvfile_returns_to_upload_page(app_, db_, db_session, active_user):
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
client.login(active_user)
upload_data = {'file': (BytesIO(''.encode('utf-8')), 'emtpy.csv')}
response = client.post('/services/123/sms/send',
response = client.post(url_for('main.send_sms', service_id=123),
data=upload_data, follow_redirects=True)
assert response.status_code == 200
@@ -23,53 +17,49 @@ def test_upload_empty_csvfile_returns_to_upload_page(
assert 'The file emtpy.csv contained no data' in content
def test_upload_csvfile_with_invalid_phone_shows_check_page_with_errors(
app_, db_, db_session,
mocker):
@moto.mock_s3
def test_upload_csvfile_with_invalid_phone_shows_check_page_with_errors(app_,
db_,
db_session,
mocker,
active_user):
contents = 'phone\n+44 123\n+44 456'
file_data = (BytesIO(contents.encode('utf-8')), 'invalid.csv')
m_open = mock_open(read_data=contents)
_setup_mocker_for_nonemtpy_file(mocker)
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
client.login(active_user)
upload_data = {'file': file_data}
with mock.patch('app.main.views.sms._open', m_open):
response = client.post('/services/123/sms/send',
data=upload_data,
follow_redirects=True)
response = client.post(url_for('main.send_sms', service_id=123),
data=upload_data,
follow_redirects=True)
assert response.status_code == 200
content = response.get_data(as_text=True)
assert 'There was a problem with some of the numbers' in content
assert 'The following numbers are invalid' in content
assert '+44 123' in content
assert '+44 456' in content
assert 'Go back and resolve errors' in content
def test_upload_csvfile_with_valid_phone_shows_first3_and_last3_numbers(
app_, db_, db_session,
mocker):
@moto.mock_s3
def test_upload_csvfile_with_valid_phone_shows_first3_and_last3_numbers(app_,
db_,
db_session,
mocker,
active_user):
contents = 'phone\n+44 7700 900981\n+44 7700 900982\n+44 7700 900983\n+44 7700 900984\n+44 7700 900985\n+44 7700 900986\n+44 7700 900987\n+44 7700 900988\n+44 7700 900989' # noqa
file_data = (BytesIO(contents.encode('utf-8')), 'valid.csv')
m_open = mock_open(read_data=contents)
_setup_mocker_for_nonemtpy_file(mocker)
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
client.login(active_user)
upload_data = {'file': file_data}
with mock.patch('app.main.views.sms._open', m_open):
response = client.post('/services/123/sms/send',
data=upload_data,
follow_redirects=True)
response = client.post(url_for('main.send_sms', service_id=123),
data=upload_data,
follow_redirects=True)
content = response.get_data(as_text=True)
@@ -88,25 +78,24 @@ def test_upload_csvfile_with_valid_phone_shows_first3_and_last3_numbers(
assert '+44 7700 900989' in content
def test_upload_csvfile_with_valid_phone_shows_all_if_6_or_less_numbers(
app_, db_, db_session,
mocker):
@moto.mock_s3
def test_upload_csvfile_with_valid_phone_shows_all_if_6_or_less_numbers(app_,
db_,
db_session,
mocker,
active_user):
contents = 'phone\n+44 7700 900981\n+44 7700 900982\n+44 7700 900983\n+44 7700 900984\n+44 7700 900985\n+44 7700 900986' # noqa
file_data = (BytesIO(contents.encode('utf-8')), 'valid.csv')
m_open = mock_open(read_data=contents)
_setup_mocker_for_nonemtpy_file(mocker)
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
client.login(active_user)
upload_data = {'file': file_data}
with mock.patch('app.main.views.sms._open', m_open):
response = client.post('/services/123/sms/send',
data=upload_data,
follow_redirects=True)
response = client.post(url_for('main.send_sms', service_id=123),
data=upload_data,
follow_redirects=True)
content = response.get_data(as_text=True)
@@ -121,34 +110,12 @@ def test_upload_csvfile_with_valid_phone_shows_all_if_6_or_less_numbers(
assert '+44 7700 900986' in content
def test_should_redirect_to_job(app_, db_,
db_session, mocker):
_setup_mocker_for_check(mocker)
@moto.mock_s3
def test_should_redirect_to_job(app_, db_, db_session, mocker, active_user):
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
with client.session_transaction() as s:
s[456] = 'test.csv'
response = client.post('/services/123/sms/check',
data={'recipients': 'test.csv'})
client.login(active_user)
response = client.post(url_for('main.check_sms',
service_id=123,
upload_id='someid'))
assert response.status_code == 302
def _setup_mocker_for_empty_file(mocker):
mocker.patch('werkzeug.datastructures.FileStorage.save')
mocker.patch('os.remove')
ret = ValueError('The file emtpy.csv contained no data')
mocker.patch('app.main.views.sms._check_file', side_effect=ret)
def _setup_mocker_for_nonemtpy_file(mocker):
mocker.patch('werkzeug.datastructures.FileStorage.save')
mocker.patch('os.remove')
mocker.patch('app.main.views.sms._check_file')
def _setup_mocker_for_check(mocker):
mocker.patch('app.main.views.sms.s3upload').return_value = 456

View File

@@ -1,32 +1,49 @@
from tests import create_test_user
from flask import url_for
def test_should_return_list_of_all_templates(app_, db_, db_session):
def test_should_return_list_of_all_templates(app_, db_, db_session, active_user):
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
response = client.get('/services/123/templates')
client.login(active_user)
response = client.get(url_for('.manage_templates', service_id=123))
assert response.status_code == 200
def test_should_show_page_for_one_templates(app_, db_, db_session):
def test_should_show_page_for_one_templates(app_, db_, db_session, active_user):
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
response = client.get('/services/123/templates/template')
client.login(active_user)
response = client.get(url_for('.edit_template', service_id=123, template_id=1))
assert response.status_code == 200
def test_should_redirect_when_saving_a_template(app_, db_, db_session):
def test_should_redirect_when_saving_a_template(app_, db_, db_session, active_user):
with app_.test_request_context():
with app_.test_client() as client:
user = create_test_user('active')
client.login(user)
response = client.post('/services/123/templates/template')
client.login(active_user)
response = client.post(url_for('.edit_template', service_id=123, template_id=1))
assert response.status_code == 302
assert response.location == 'http://localhost/services/123/templates'
assert response.status_code == 302
assert response.location == url_for('.manage_templates', service_id=123, _external=True)
def test_should_show_delete_template_page(app_, db_, db_session, active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.get(url_for('.delete_template', service_id=123, template_id=1))
assert response.status_code == 200
assert 'Are you sure' in response.get_data(as_text=True)
def test_should_redirect_when_deleting_a_template(app_, db_, db_session, active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.post(url_for('.delete_template', service_id=123, template_id=1))
assert response.status_code == 302
assert response.location == url_for('.manage_templates', service_id=123, _external=True)

View File

@@ -32,7 +32,7 @@ def test_should_redirect_to_add_service_when_code_are_correct(app_,
data={'sms_code': '12345',
'email_code': '23456'})
assert response.status_code == 302
assert response.location == url_for('main.add_service', _external=True)
assert response.location == url_for('main.add_service', first='first', _external=True)
def test_should_activate_user_after_verify(app_, db_, db_session):