Merge branch 'master' of github.com:alphagov/notifications-admin

This commit is contained in:
Martyn Inglis
2016-03-01 09:57:52 +00:00
39 changed files with 493 additions and 344 deletions

View File

@@ -15,6 +15,8 @@ from app.notify_client.api_key_api_client import ApiKeyApiClient
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
@@ -30,7 +32,9 @@ 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()
def create_app(config_name, config_overrides=None):
@@ -47,6 +51,8 @@ def create_app(config_name, config_overrides=None):
api_key_api_client.init_app(application)
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'

View File

@@ -15,3 +15,7 @@
margin-bottom: $gutter;
clear: both;
}
.bottom-gutter-2-3 {
margin-bottom: $gutter * 2/3;
}

View File

@@ -164,13 +164,13 @@ class AddServiceForm(Form):
name = StringField(
'Service name',
validators=[
DataRequired(message='Service name can not be empty')
DataRequired(message='Service name cant 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):

View File

@@ -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())
])

View File

@@ -3,6 +3,7 @@ from flask_login import login_required
from app.main import main
from app.main.forms import CreateKeyForm
from app import api_key_api_client
from app.utils import user_has_permissions
@main.route("/services/<service_id>/documentation")
@@ -13,6 +14,7 @@ def documentation(service_id):
@main.route("/services/<service_id>/api-keys")
@login_required
@user_has_permissions('manage_api_keys')
def api_keys(service_id):
return render_template(
'views/api-keys.html',
@@ -23,6 +25,7 @@ def api_keys(service_id):
@main.route("/services/<service_id>/api-keys/create", methods=['GET', 'POST'])
@login_required
@user_has_permissions('manage_api_keys')
def create_api_key(service_id):
key_names = [
key['name'] for key in api_key_api_client.get_api_keys(service_id=service_id)['apiKeys']
@@ -41,6 +44,7 @@ def create_api_key(service_id):
@main.route("/services/<service_id>/api-keys/revoke/<int:key_id>", methods=['GET', 'POST'])
@login_required
@user_has_permissions('manage_api_keys')
def revoke_api_key(service_id, key_id):
key_name = api_key_api_client.get_api_keys(service_id=service_id, key_id=key_id)['apiKeys'][0]['name']
if request.method == 'GET':

View File

@@ -23,6 +23,7 @@ def service_dashboard(service_id):
try:
service = get_service_by_id(service_id)
session['service_name'] = service['data']['name']
session['service_id'] = service['data']['id']
except HTTPError as e:
if e.status_code == 404:
abort(404)

View File

@@ -13,6 +13,7 @@ from notification_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/<service_id>/jobs/<job_id>")
@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:

View File

@@ -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,14 +34,19 @@ fake_users = [
@main.route("/services/<service_id>/users")
@login_required
def manage_users(service_id):
users = user_api_client.get_users_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=[]
)
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/<service_id>/users/invite", methods=['GET', 'POST'])
@@ -44,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',
@@ -95,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)

View File

@@ -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
@@ -32,16 +28,23 @@ from app.main.dao import services_dao
from app import job_api_client
from app.utils import validate_recipient, InvalidPhoneError, InvalidEmailError
first_column_header = {
'email': 'email',
'sms': 'phone'
page_headings = {
'email': 'Send emails',
'sms': 'Send text messages'
}
@main.route("/services/<service_id>/send/letters", methods=['GET'])
def letters_stub(service_id):
return render_template(
'views/letters.html', service_id=service_id
)
@main.route("/services/<service_id>/send/<template_type>", methods=['GET'])
def choose_template(service_id, template_type):
services_dao.get_service_by_id_or_404(service_id)
service = services_dao.get_service_by_id_or_404(service_id)
if template_type not in ['email', 'sms']:
abort(404)
@@ -53,11 +56,17 @@ def choose_template(service_id, template_type):
else:
raise e
return render_template(
'views/choose-{}-template.html'.format(template_type),
'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
)
@@ -70,7 +79,7 @@ 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'])
@@ -79,19 +88,20 @@ def send_messages(service_id, template_id):
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_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.html',
template=template,
column_headers=[first_column_header[template.template_type]] + template.placeholders_as_markup,
column_headers=['to'] + template.placeholders_as_markup,
form=form,
service=service,
service_id=service_id
@@ -105,10 +115,14 @@ def get_example_csv(service_id, template_id):
placeholders = list(Template(template).placeholders)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([first_column_header[template['template_type']]] + 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/<service_id>/send/<template_id>/to-self", methods=['GET'])
@@ -118,7 +132,7 @@ def send_message_to_self(service_id, template_id):
placeholders = list(Template(template).placeholders)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([first_column_header[template['template_type']]] + placeholders)
writer.writerow(['to'] + placeholders)
writer.writerow([current_user.mobile_number] + ["test {}".format(header) for header in placeholders])
filedata = {
'file_name': 'Test run',
@@ -140,29 +154,30 @@ 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')
raw_template = templates_dao.get_service_template_or_404(service_id, template_id)['data']
recipient_type = first_column_header[raw_template['template_type']]
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={recipient_type}
drop_values={'to'},
prefix=service['name']
)
return render_template(
'views/check-sms.html',
'views/check.html',
upload_result=upload_result,
template=template,
column_headers=[recipient_type] + 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':
@@ -184,17 +199,34 @@ def check_messages(service_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
)
@@ -203,12 +235,11 @@ def _get_rows(contents, raw_template):
for row in reader:
rows.append(row)
try:
recipient_column = first_column_header[raw_template['template_type']]
validate_recipient(
row[recipient_column],
row.get('to', ''),
template_type=raw_template['template_type']
)
Template(raw_template, values=row, drop_values={recipient_column}).replaced
Template(raw_template, values=row, drop_values={'to'}).replaced
except (InvalidEmailError, InvalidPhoneError, NeededByTemplateError, NoPlaceholderForDataError):
valid = False
return {"valid": valid, "rows": rows}

View File

@@ -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']

View File

@@ -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),

View File

@@ -0,0 +1,25 @@
import uuid
from notifications_python_client.base import BaseAPIClient
class PermissionApiClient(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 delete_permission(self, permission_id):
return self.delete(url='/permission/{}'.format(permission_id))['data']
def create_permission(self, permission, user_id, service_id):
return self.post(
url='/permission',
data={'permission': permission,
'user': user_id,
'service': service_id})['data']

View File

@@ -94,7 +94,7 @@ class User(UserMixin):
self._email_address = fields.get('email_address')
self._mobile_number = fields.get('mobile_number')
self._password_changed_at = fields.get('password_changed_at')
self._permissions = set(fields.get('permissions')) if fields.get('permission') is not None else set()
self._permissions = fields.get('permissions')
self._failed_login_count = 0
self._state = fields.get('state')
self.max_failed_login_count = max_failed_login_count
@@ -165,18 +165,12 @@ class User(UserMixin):
@permissions.setter
def permissions(self, permissions):
if permissions is None:
permissions = set()
self._permissions = set(permissions)
raise AttributeError("Read only property")
def add_permissions(self, permissions):
self._permissions.update(permissions)
def remove_permissions(self, permissions):
self._permissions -= permissions
def has_permissions(self, permissions):
return self._permissions > set(permissions)
def has_permissions(self, service_id, permissions):
if service_id in self._permissions:
return set(self._permissions[service_id]) > set(permissions)
return False
@property
def failed_login_count(self):

View File

@@ -5,11 +5,11 @@
</legend>
<div class='yes-no-fields inline'>
<label class='block-label'>
<input type='radio' name='{{ name }}' {% if current_value == True %}checked{% endif %} />
<input type='radio' name='{{ name }}' value='yes' {% if current_value == True %}checked{% endif %} />
Yes
</label>
<label class='block-label'>
<input type='radio' name='{{ name }}' {% if current_value == False %}checked{% endif %} />
<input type='radio' name='{{ name }}' value='no' {% if current_value == False %}checked{% endif %} />
No
</label>
</div>

View File

@@ -2,16 +2,23 @@
<h2 class="navigation-service-name">
<a href="{{ url_for('.service_dashboard', service_id=service_id) }}">{{ session.get('service_name', 'Service') }}</a>
</h2>
{% if current_user.has_permissions(session.get('service_id', ''), ['send_messages']) %}
<ul>
<li><a href="{{ url_for('.choose_template', service_id=service_id, template_type='sms') }}">Send text messages</a></li>
<li><a href="{{ url_for('.choose_template', service_id=service_id, template_type='email') }}">Send emails</a></li>
<li><a href="{{ url_for('.letters_stub', service_id=service_id) }}">Send letters</a></li>
</ul>
{% endif %}
{% if current_user.has_permissions(session.get('service_id', ''), ['manage_service']) %}
<ul>
<li><a href="{{ url_for('.manage_users', service_id=service_id) }}">Manage team</a></li>
<li><a href="{{ url_for('.service_settings', service_id=service_id) }}">Manage settings</a></li>
</ul>
{% endif %}
{% if current_user.has_permissions(session.get('service_id', ''), ['manage_api_keys']) %}
<ul>
<li><a href="{{ url_for('.api_keys', service_id=service_id) }}">API keys</a></li>
<li><a href="{{ url_for('.documentation', service_id=service_id) }}">Developer documentation</a></li>
</ul>
{% endif %}
</nav>

View File

@@ -1,42 +0,0 @@
{% extends "withoutnav_template.html" %}
{% from "components/textbox.html" import textbox %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/sms-message.html" import sms_message %}
{% from "components/email-message.html" import email_message %}
{% block page_title %}
Preview your service name GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">
Preview your service name
</h1>
<div class="grid-row">
<div class="column-two-thirds">
{{ sms_message(
"{}: we received your payment, thank you".format(service_name),
name="Text message",
recipient='Sent from 40604'
) }}
</div>
</div>
<div class="grid-row">
<div class='column-two-thirds'>
{{ email_message(
subject="We received your payment, thank you",
body="Dear Alice Smith,\n\nThank you for…",
from_name=service_name,
from_address=from_address,
name="Email",
) }}
</div>
</div>
<form method="post">
{{page_footer('Looks good', back_link=url_for(".add_service"))}}
</form>
{% endblock %}

View File

@@ -16,16 +16,29 @@
</h1>
<p>
Be specific. Remember that there might be other people in your
organisation using GOV.UK Notify.
Be specific to your service. Remember that there might be
other people in your organisation using GOV.UK Notify.
</p>
<p>
Users will see this:
</p>
<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, hint="You can change this later") }}
{{ page_footer('Continue') }}
{{ page_footer('Add service') }}
</form>

View File

@@ -1,18 +0,0 @@
{% extends "withnav_template.html" %}
{% block page_title %}
Send email GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">Send email</h1>
<p>This page will be where we check the email messages we're about to send</p>
<p>
<a class="button" href="dashboard" role="button">Send email messages</a>
</p>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "withnav_template.html" %}
{% from "components/email-message.html" import email_message %}
{% from "components/sms-message.html" import sms_message %}
{% from "components/table.html" import list_table, field %}
{% from "components/placeholder.html" import placeholder %}
@@ -6,7 +7,7 @@
{% from "components/page-footer.html" import page_footer %}
{% block page_title %}
Check and confirm GOV.UK Notify
{{ "Check and confirm" if upload_result.valid else page_heading }} GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
@@ -25,18 +26,25 @@
{% endif %}
<h1 class="heading-large">
{{ "Check and confirm" if upload_result.valid else "Send text messages" }}
{{ "Check and confirm" if upload_result.valid else page_heading }}
</h1>
<div class="grid-row">
<div class="column-two-thirds">
{% if template.missing_data or template.additional_data %}
{{ sms_message(template.formatted_as_markup)}}
{% else %}
{{ sms_message(template.replaced)}}
{% endif %}
{% if 'email' == template.template_type %}
{{ email_message(
template.subject,
template.replaced if upload_result.valid else template.formatted_as_markup,
from_address='{}@notifications.service.gov.uk'.format(service.email_from),
from_name=service.name
)}}
{% elif 'sms' == template.template_type %}
<div class="grid-row">
<div class="column-two-thirds">
{{ sms_message(
template.replaced if upload_result.valid else template.formatted_as_markup
)}}
</div>
</div>
</div>
{% endif %}
{% if upload_result.valid %}
<form method="post" enctype="multipart/form-data">
@@ -53,13 +61,13 @@
caption=original_file_name,
field_headings=column_headers
) %}
{% if item.phone|valid_phone_number %}
{% if item.to or ''|valid_phone_number %}
{% call field() %}
{{ item.phone }}
{{ item.to }}
{% endcall %}
{% else %}
{% call field(status='missing') %}
{{ item.phone }}
{{ item.to }}
{% endcall %}
{% endif %}
{% for column in template.placeholders %}

View File

@@ -1,36 +0,0 @@
{% extends "withnav_template.html" %}
{% from "components/email-message.html" import email_message %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/textbox.html" import textbox %}
{% block page_title %}
Send emails GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">Send emails</h1>
<form method="POST" enctype="multipart/form-data">
{% if templates %}
<div class="grid-row">
{% for template in templates %}
<div class="column-two-thirds">
{{ email_message(template.subject, template.formatted_as_markup, name=template.name) }}
</div>
<div class="column-one-third">
<div class="sms-message-use-links">
<a href="{{ url_for(".edit_service_template", service_id=service_id, template_id=template.id) }}">Edit template</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<p>
<a href="{{ url_for('.add_service_template', service_id=service_id, template_type='email') }}" class="button">Add a new template</a>
</p>
</form>
{% endblock %}

View File

@@ -1,15 +1,16 @@
{% extends "withnav_template.html" %}
{% from "components/email-message.html" import email_message %}
{% from "components/sms-message.html" import sms_message %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/textbox.html" import textbox %}
{% block page_title %}
Send text messages GOV.UK Notify
{{ page_heading }} GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">Send text messages</h1>
<h1 class="heading-large">{{ page_heading }}</h1>
<form method="POST" enctype="multipart/form-data">
@@ -26,7 +27,15 @@
<div class="grid-row">
{% for template in templates %}
<div class="column-two-thirds">
{{ sms_message(template.formatted_as_markup, name=template.name) }}
{% if 'email' == template_type %}
{{ email_message(
template.subject,
template.formatted_as_markup,
name=template.name
) }}
{% elif 'sms' == template_type %}
{{ sms_message(template.formatted_as_markup, name=template.name) }}
{% endif %}
</div>
<div class="column-one-third">
<div class="sms-message-use-links">
@@ -40,7 +49,7 @@
{% endif %}
<p>
<a href="{{ url_for('.add_service_template', service_id=service_id, template_type='sms') }}" class="button">Add a new template</a>
<a href="{{ url_for('.add_service_template', service_id=service_id, template_type=template_type) }}" class="button">Add a new template</a>
</p>
</form>

View File

@@ -161,7 +161,7 @@
<h2 class="heading-medium">API endpoint</h2>
<p>
https://www.notify.works/api/endpoint
https://api.notify.works
</p>
<p>

View File

@@ -28,6 +28,7 @@ Manage users GOV.UK Notify
<legend class='heading-small'>
Permissions
</legend>
<span class="form-hint">All team members can see message history</span>
{{ yes_no('send_messages', 'Send messages', user.permission_send_messages) }}
{{ yes_no('manage_service', 'Manage service', user.permission_manage_service) }}
{{ yes_no('manage_api_keys', 'Manage API keys', user.permission_manage_api_keys) }}

View File

@@ -14,13 +14,22 @@
{{ uploaded_file_name }}
</h1>
<div class="grid-row">
<div class="column-two-thirds">
{{ sms_message(
template.formatted_as_markup,
)}}
{% if 'sms' == template.template_type %}
<div class="grid-row">
<div class="column-two-thirds">
{{ sms_message(
template,
)}}
</div>
</div>
</div>
{% elif 'email' == template.template_type %}
{{ email_message(
template.subject,
template,
from_address='{}@notifications.service.gov.uk'.format(service.email_from),
from_name=service.name
)}}
{% endif %}
<p class='heading-small'>
Started {{ uploaded_file_time|format_datetime }}

View File

@@ -0,0 +1,17 @@
{% extends "withnav_template.html" %}
{% block page_title %}
Send letters GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">
Send letters
</h1>
<p>
This page is where you would go to send letters.
</p>
{% endblock %}

View File

@@ -41,7 +41,7 @@ Manage users GOV.UK Notify
invited_users, caption='Invited', **table_options
) %}
{% call field() %}
{{ item.email_localpart }}
{{ item.email_address }}
{% endcall %}
{{ boolean_field(item.permission_send_messages) }}
{{ boolean_field(item.permission_manage_service) }}

View File

@@ -13,31 +13,42 @@
<h1 class="heading-large">Add recipients</h1>
{% if 'sms' == template.template_type %}
<div class="grid-row">
<div class="column-two-thirds">
{{ sms_message(template.formatted_as_markup) }}
</div>
</div>
{% elif 'email' == template.template_type %}
{{ email_message(
template.subject,
template.formatted_as_markup,
from_address='{}@notifications.service.gov.uk'.format(service.email_from),
from_name=service.name
) }}
{% endif %}
<div class="grid-row">
<div class="column-two-thirds">
{% if 'sms' == template.template_type %}
{{ sms_message(template.formatted_as_markup) }}
{% elif 'email' == template.template_type %}
{{ email_message(
template.subject,
template.formatted_as_markup,
from_address='{}@notifications.service.gov.uk'.format(service.email_from),
from_name=service.name
) }}
{% endif %}
{{ banner(
'You can upload real data, but well only send to your mobile number until you <a href="{}">request to go live</a>'.format(
url_for('.service_request_to_go_live', service_id=service_id)
)|safe,
'info'
)}}
<p>
Add recipients by uploading a .csv file with
{{ template.placeholders|length + 1 }}
{% if template.placeholders %}
columns:
{% else %}
column:
{% endif %}
</p>
<p class="bottom-gutter-2-3">
<span class='placeholder'>to</span>
{{ template.placeholders_as_markup|join(" ") }}
</p>
<p>
<a href="{{ url_for('.get_example_csv', service_id=service_id, template_id=template.id) }}">Download an example</a>
</p>
</div>
</div>
{{file_upload(form.file, button_text='Upload a CSV file')}}
<p>
<a href="{{ url_for('.get_example_csv', service_id=service_id, template_id=template.id) }}">Download an example CSV file</a>
</p>
{{file_upload(form.file, button_text='Upload your CSV file')}}
{% endblock %}

View File

@@ -1,7 +1,7 @@
import re
from functools import wraps
from flask import abort
from flask import (abort, session)
class BrowsableItem(object):
@@ -100,8 +100,10 @@ def user_has_permissions(*permissions):
def wrap_func(*args, **kwargs):
# We are making the assumption that the user is logged in.
from flask_login import current_user
if set(permissions) > set(current_user.permissions):
service_id = session.get('service_id', '')
if current_user and current_user.has_permissions(service_id, permissions):
return func(*args, **kwargs)
else:
abort(403)
return func(*args, **kwargs)
return wrap_func
return wrap

View File

@@ -29,9 +29,8 @@
"gulp-base64": "0.1.3",
"gulp-concat": "2.6.0",
"gulp-include": "2.1.0",
"gulp-jquery": "1.1.1",
"gulp-load-plugins": "1.1.0",
"gulp-sass": "2.1.1",
"gulp-sass": "2.2.0",
"gulp-uglify": "1.5.1",
"jquery": "1.11.2",
"query-command-supported": "1.0.0"

View File

@@ -14,4 +14,4 @@ Pygments==2.0.2
git+https://github.com/alphagov/notifications-python-client.git@0.2.8#egg=notifications-python-client==0.2.8
git+https://github.com/alphagov/notifications-utils.git@0.1.1#egg=notifications-utils==0.1.1
git+https://github.com/alphagov/notifications-utils.git@0.1.2#egg=notifications-utils==0.1.2

View File

@@ -47,6 +47,15 @@ def api_key_json(id_, name, expiry_date=None):
'expiry_date': expiry_date
}
def invite_json(id, from_user, service_id, email_address):
return {'id': id,
'from_user': from_user,
'service': service_id,
'email_address': email_address,
'status': 'pending'}
TEST_USER_EMAIL = 'test@user.gov.uk'
@@ -57,14 +66,15 @@ def create_test_user(state):
return user
def create_test_api_user(state):
def create_test_api_user(state, permissions={}):
from app.notify_client.user_api_client import User
user_data = {'id': 1,
'name': 'Test User',
'password': 'somepassword',
'email_address': TEST_USER_EMAIL,
'mobile_number': '+441234123412',
'state': state
'state': state,
'permissions': permissions
}
user = User(user_data)
return user

View File

@@ -11,7 +11,6 @@ def test_client_creates_job_data_correctly(mocker):
expected_data = {
"id": job_id,
"service": service_id,
"template": template_id,
"original_file_name": original_file_name,
"bucket_name": "service-{}-notify".format(service_id),

View File

@@ -9,4 +9,4 @@ def test_form_should_have_errors_when_duplicate_service_is_added(app_):
form = AddServiceForm(_get_form_names,
formdata=MultiDict([('name', 'some service')]))
form.validate()
assert {'name': ['Service name already exists']} == form.errors
assert {'name': ['This service name is already in use']} == form.errors

View File

@@ -14,7 +14,7 @@ def test_get_should_render_add_service_template(app_,
client.login(api_user_active)
response = client.get(url_for('main.add_service'))
assert response.status_code == 200
assert 'Add a new service' in response.get_data(as_text=True)
assert 'Which service do you want to set up notifications for?' in response.get_data(as_text=True)
def test_should_add_service_and_redirect_to_next_page(app_,
@@ -31,48 +31,8 @@ def test_should_add_service_and_redirect_to_next_page(app_,
url_for('main.add_service'),
data={'name': 'testing the post'})
assert response.status_code == 302
assert response.location == url_for('main.add_from_address', _external=True)
def test_should_confirm_add_service(
app_,
mock_login,
mock_get_services,
api_user_active,
mock_get_user,
mock_get_user_by_email
):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
with client.session_transaction() as session:
session['service_name'] = 'Renew Your Pet Passport'
response = client.get(url_for('main.add_from_address'))
assert response.status_code == 200
assert 'Preview your service name' in response.get_data(as_text=True)
assert 'Renew Your Pet Passport' in response.get_data(as_text=True)
assert 'renew.your.pet.passport@notifications.service.gov.uk' in response.get_data(as_text=True)
def test_should_add_service_after_confirmation(
app_,
mock_login,
mock_create_service,
mock_get_services,
api_user_active,
mock_get_user,
mock_get_user_by_email
):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
with client.session_transaction() as session:
session['service_name'] = 'Renew Your Pet Passport'
response = client.post(url_for('main.add_from_address'))
assert response.status_code == 302
assert response.location == url_for('main.service_dashboard', service_id=101, _external=True)
assert mock_create_service.called
assert mock_get_services.called
def test_should_return_form_errors_when_service_name_is_empty(app_,
@@ -87,7 +47,7 @@ def test_should_return_form_errors_when_service_name_is_empty(app_,
client.login(api_user_active)
response = client.post(url_for('main.add_service'), data={})
assert response.status_code == 200
assert 'Service name can not be empty' in response.get_data(as_text=True)
assert 'Service name cant be empty' in response.get_data(as_text=True)
def test_should_return_form_errors_with_duplicate_service_name(app_,
@@ -102,5 +62,5 @@ def test_should_return_form_errors_with_duplicate_service_name(app_,
response = client.post(
url_for('main.add_service'), data={'name': 'service_one'})
assert response.status_code == 200
assert 'Service name already exists' in response.get_data(as_text=True)
assert 'This service name is already in use' in response.get_data(as_text=True)
assert mock_get_services.called

View File

@@ -21,7 +21,8 @@ def test_should_show_empty_api_keys_page(app_,
mock_get_user,
mock_get_user_by_email,
mock_get_no_api_keys,
mock_login):
mock_login,
mock_has_permissions):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
@@ -39,7 +40,8 @@ def test_should_show_api_keys_page(app_,
mock_get_user,
mock_get_user_by_email,
mock_get_api_keys,
mock_login):
mock_login,
mock_has_permissions):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
@@ -58,7 +60,8 @@ def test_should_show_name_api_key_page(app_,
mock_get_user,
mock_get_user_by_email,
mock_get_api_keys,
mock_login):
mock_login,
mock_has_permissions):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
@@ -74,7 +77,8 @@ def test_should_render_show_api_key(app_,
mock_get_user_by_email,
mock_create_api_key,
mock_get_api_keys,
mock_login):
mock_login,
mock_has_permissions):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
@@ -92,7 +96,8 @@ def test_should_show_confirm_revoke_api_key(app_,
mock_get_user,
mock_get_user_by_email,
mock_get_api_keys,
mock_login):
mock_login,
mock_has_permissions):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
@@ -110,7 +115,8 @@ def test_should_redirect_after_revoking_api_key(app_,
mock_get_user_by_email,
mock_revoke_api_key,
mock_get_api_keys,
mock_login):
mock_login,
mock_has_permissions):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)

View File

@@ -27,6 +27,7 @@ def test_should_show_page_for_one_job(app_,
mock_login,
mock_get_user,
mock_get_user_by_email,
mock_get_service,
mock_get_service_template,
job_data,
mock_get_job):

View File

@@ -1,13 +1,15 @@
import json
from flask import url_for
from bs4 import BeautifulSoup
def test_should_show_overview_page(
app_,
api_user_active,
mock_login,
mock_get_service,
mock_get_users_by_service
mock_get_users_by_service,
mock_get_invites_for_service
):
with app_.test_request_context():
with app_.test_client() as client:
@@ -38,7 +40,8 @@ def test_redirect_after_saving_user(
api_user_active,
mock_login,
mock_get_service,
mock_get_users_by_service
mock_get_users_by_service,
mock_get_invites_for_service
):
with app_.test_request_context():
with app_.test_client() as client:
@@ -70,19 +73,34 @@ def test_should_show_page_for_inviting_user(
def test_invite_user(
app_,
service_one,
api_user_active,
mock_login,
mock_get_service,
mock_get_users_by_service
mock_get_users_by_service,
mock_create_invite,
mock_get_invites_for_service
):
from_user = api_user_active.id
service_id = service_one['id']
email_address = 'test@example.gov.uk'
permissions = 'send_messages,manage_service,manage_api_keys'
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
response = client.post(
url_for('main.invite_user', service_id=55555),
data={'email_address': 'test@example.gov.uk'},
url_for('main.invite_user', service_id=service_id),
data={'email_address': email_address,
'send_messages': 'yes',
'manage_service': 'yes',
'manage_api_keys': 'yes'},
follow_redirects=True
)
assert response.status_code == 200
assert 'Invite sent to test@example.gov.uk' in response.get_data(as_text=True)
mock_create_invite.assert_called_with(from_user, service_id, email_address, permissions)
mock_get_invites_for_service.assert_called_with(service_id=service_id)
page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
assert page.h1.string.strip() == 'Manage team'
flash_banner = page.find('div', class_='banner-default-with-tick').string.strip()
assert flash_banner == 'Invite sent to test@example.gov.uk'

View File

@@ -1,8 +1,8 @@
import pytest
from io import BytesIO
from flask import url_for
import pytest
import moto
from unittest.mock import ANY
template_types = ['email', 'sms']
@@ -57,17 +57,19 @@ def test_upload_empty_csvfile_returns_to_upload_page(
assert 'The file emtpy.csv contained no data' in content
@pytest.mark.skipif(True, reason='Errors on travis')
@moto.mock_s3
def test_upload_csvfile_with_invalid_phone_shows_check_page_with_errors(
app_,
api_user_active,
mocker,
mock_login,
mock_get_service_template
mock_get_service,
mock_get_service_template,
mock_s3_upload
):
contents = 'phone\n+44 123\n+44 456'
contents = 'to,name\n+44 123,test1\n+44 456,test2'
file_data = (BytesIO(contents.encode('utf-8')), 'invalid.csv')
mocker.patch('app.main.views.send.s3download', return_value=contents)
with app_.test_request_context():
with app_.test_client() as client:
@@ -86,18 +88,51 @@ def test_upload_csvfile_with_invalid_phone_shows_check_page_with_errors(
assert 'Upload a CSV file' in content
@pytest.mark.skipif(True, reason='Errors on travis')
@moto.mock_s3
def test_upload_csvfile_removes_empty_lines_and_trailing_commas(
app_,
api_user_active,
mocker,
mock_login,
mock_get_service,
mock_get_service_template,
mock_s3_upload
):
contents = 'to,name,,,\n++44 7700 900981,test1,,,\n+44 7700 900981,test2,,,\n ,,, \n ,,, \t \t \n'
file_data = (BytesIO(contents.encode('utf-8')), 'invalid.csv')
expected_data = {'data': ['to,name', '++44 7700 900981,test1', '+44 7700 900981,test2'],
'file_name': 'invalid.csv'}
mocker.patch('app.main.views.send.s3download',
return_value='to,name\n++44 7700 900981,test1\n+44 7700 900981,test2')
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
upload_data = {'file': file_data}
response = client.post(
url_for('main.send_messages', service_id=12345, template_id=54321),
data=upload_data,
follow_redirects=True
)
assert response.status_code == 200
mock_s3_upload.assert_called_with(ANY, '12345', expected_data, 'eu-west-1')
def test_send_test_message_to_self(
app_,
mocker,
api_user_active,
mock_login,
mock_get_user,
mock_get_user_by_email,
mock_get_service_template
mock_get_service,
mock_get_service_template,
mock_s3_upload
):
expected_data = {'data': ['to', '+4412341234'], 'file_name': 'Test run'}
mocker.patch('app.main.views.send.s3download', return_value='to\r\n+4412341234')
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
@@ -106,20 +141,15 @@ def test_send_test_message_to_self(
follow_redirects=True
)
assert response.status_code == 200
content = response.get_data(as_text=True)
assert 'Test run' in content
assert '+4412341234' in content
mock_s3_upload.assert_called_with(ANY, '12345', expected_data, 'eu-west-1')
@pytest.mark.skipif(True, reason='Errors on travis')
@moto.mock_s3
def test_download_example_csv(
app_,
mocker,
api_user_active,
mock_login,
mock_get_user,
mock_get_user_by_email,
mock_get_service,
mock_get_service_template
):
@@ -131,25 +161,23 @@ def test_download_example_csv(
follow_redirects=True
)
assert response.status_code == 200
assert response.get_data(as_text=True) == 'phone\r\n+4412341234\r\n'
assert response.get_data(as_text=True) == 'to\r\n+4412341234\r\n'
assert 'text/csv' in response.headers['Content-Type']
@pytest.mark.skipif(True, reason='Errors on travis')
@moto.mock_s3
def test_upload_csvfile_with_valid_phone_shows_all_numbers(
app_,
mocker,
api_user_active,
mock_login,
mock_get_user,
mock_get_user_by_email,
mock_get_service_template
mock_get_service,
mock_get_service_template,
mock_s3_upload
):
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
contents = 'to\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')
mocker.patch('app.main.views.send.s3download', return_value=contents)
with app_.test_request_context():
with app_.test_client() as client:
@@ -174,18 +202,15 @@ def test_upload_csvfile_with_valid_phone_shows_all_numbers(
assert '+44 7700 900986' in content
@pytest.mark.skipif(True, reason='Errors on travis')
@moto.mock_s3
def test_create_job_should_call_api(
app_,
service_one,
api_user_active,
mock_get_user,
mock_get_user_by_email,
mock_login,
job_data,
mock_create_job,
mock_get_job,
mock_get_service,
mock_get_service_template
):

View File

@@ -1,4 +1,4 @@
import os
import uuid
from datetime import date
import pytest
@@ -9,7 +9,8 @@ from . import (
TestClient,
template_json,
api_key_json,
job_json
job_json,
invite_json
)
@@ -204,7 +205,8 @@ def api_user_pending():
'email_address': 'test@user.gov.uk',
'mobile_number': '+4412341234',
'state': 'pending',
'failed_login_count': 0
'failed_login_count': 0,
'permissions': {}
}
user = User(user_data)
return user
@@ -219,7 +221,8 @@ def api_user_active():
'email_address': 'test@user.gov.uk',
'mobile_number': '+4412341234',
'state': 'active',
'failed_login_count': 0
'failed_login_count': 0,
'permissions': {}
}
user = User(user_data)
return user
@@ -234,7 +237,8 @@ def api_user_locked():
'email_address': 'test@user.gov.uk',
'mobile_number': '+4412341234',
'state': 'active',
'failed_login_count': 5
'failed_login_count': 5,
'permissions': {}
}
user = User(user_data)
return user
@@ -249,7 +253,8 @@ def api_user_request_password_reset():
'email_address': 'test@user.gov.uk',
'mobile_number': '+4412341234',
'state': 'request_password_reset',
'failed_login_count': 5
'failed_login_count': 5,
'permissions': {}
}
user = User(user_data)
return user
@@ -512,6 +517,15 @@ def mock_get_jobs(mocker):
return mocker.patch('app.job_api_client.get_job', side_effect=_get_jobs)
@pytest.fixture(scope='function')
def mock_has_permissions(mocker):
def _has_permission(service_id, permissions):
return True
return mocker.patch(
'app.notify_client.user_api_client.User.has_permissions',
side_effect=_has_permission)
@pytest.fixture(scope='function')
def mock_get_users_by_service(mocker):
def _get_users_for_service(service_id):
@@ -526,3 +540,37 @@ def mock_get_users_by_service(mocker):
'failed_login_count': 0}]
return data
return mocker.patch('app.user_api_client.get_users_for_service', side_effect=_get_users_for_service, autospec=True)
@pytest.fixture(scope='function')
def mock_s3_upload(mocker):
def _upload(upload_id, service_id, filedata, region):
pass
return mocker.patch('app.main.views.send.s3upload', side_effect=_upload)
@pytest.fixture(scope='function')
def mock_create_invite(mocker):
def _create_invite(from_user, service_id, email_address, permissions):
data = {'id': uuid.uuid4(),
'from_user': from_user,
'service': service_id,
'email_address': email_address,
'status': 'pending',
'permissions': permissions}
return data
return mocker.patch('app.invite_api_client.create_invite', side_effect=_create_invite)
@pytest.fixture(scope='function')
def mock_get_invites_for_service(mocker, service_one):
def _get_invites(service_id):
data = []
from_user = service_one['users'][0]
service_id = service_one['id']
for i in range(0, 5):
email_address = 'user_{}@testnotify.gov.uk'.format(i)
invite = invite_json(uuid.uuid4(), from_user, service_id, email_address)
data.append(invite)
return data
return mocker.patch('app.invite_api_client.get_invites_for_service', side_effect=_get_invites)