Merge branch 'master' of https://github.com/alphagov/notifications-api into vb-platform-admin-api

This commit is contained in:
venusbb
2017-09-22 11:14:09 +01:00
27 changed files with 758 additions and 139 deletions

View File

@@ -1,3 +1,5 @@
from sqlalchemy import desc
from app import db
from app.dao.dao_utils import transactional
from app.errors import InvalidRequest
@@ -9,7 +11,17 @@ def dao_get_reply_to_by_service_id(service_id):
ServiceEmailReplyTo
).filter(
ServiceEmailReplyTo.service_id == service_id
).all()
).order_by(desc(ServiceEmailReplyTo.is_default), desc(ServiceEmailReplyTo.created_at)).all()
return reply_to
def dao_get_reply_to_by_id(service_id, reply_to_id):
reply_to = db.session.query(
ServiceEmailReplyTo
).filter(
ServiceEmailReplyTo.service_id == service_id,
ServiceEmailReplyTo.id == reply_to_id
).order_by(ServiceEmailReplyTo.created_at).one()
return reply_to
@@ -37,3 +49,57 @@ def dao_create_reply_to_email_address(reply_to_email):
@transactional
def dao_update_reply_to_email(reply_to):
db.session.add(reply_to)
@transactional
def add_reply_to_email_address_for_service(service_id, email_address, is_default):
old_default = _get_existing_default(service_id)
if is_default:
_reset_old_default_to_false(old_default)
else:
_raise_when_no_default(old_default)
new_reply_to = ServiceEmailReplyTo(service_id=service_id, email_address=email_address, is_default=is_default)
db.session.add(new_reply_to)
return new_reply_to
@transactional
def update_reply_to_email_address(service_id, reply_to_id, email_address, is_default):
old_default = _get_existing_default(service_id)
if is_default:
_reset_old_default_to_false(old_default)
else:
if old_default.id == reply_to_id:
raise InvalidRequest("You must have at least one reply to email address as the default.", 400)
reply_to_update = ServiceEmailReplyTo.query.get(reply_to_id)
reply_to_update.email_address = email_address
reply_to_update.is_default = is_default
db.session.add(reply_to_update)
return reply_to_update
def _get_existing_default(service_id):
existing_reply_to = dao_get_reply_to_by_service_id(service_id=service_id)
if existing_reply_to:
old_default = [x for x in existing_reply_to if x.is_default]
if len(old_default) == 1:
return old_default[0]
else:
raise Exception(
"There should only be one default reply to email for each service. Service {} has {}".format(
service_id, len(old_default)))
return None
def _reset_old_default_to_false(old_default):
if old_default:
old_default.is_default = False
db.session.add(old_default)
def _raise_when_no_default(old_default):
# check that the update is not updating the only default to false
if not old_default:
raise InvalidRequest("You must have at least one reply to email address as the default.", 400)

View File

@@ -19,6 +19,7 @@ from app.models import (
SMS_TYPE,
KEY_TYPE_TEST,
BRANDING_ORG,
BRANDING_ORG_BANNER,
BRANDING_GOVUK,
EMAIL_TYPE,
NOTIFICATION_TECHNICAL_FAILURE,
@@ -108,13 +109,14 @@ def send_email_to_provider(notification):
else:
from_address = '"{}" <{}@{}>'.format(service.name, service.email_from,
current_app.config['NOTIFY_EMAIL_DOMAIN'])
reference = provider.send_email(
from_address,
notification.to,
plain_text_email.subject,
body=str(plain_text_email),
html_body=str(html_email),
reply_to_address=service.reply_to_email_address,
reply_to_address=service.get_default_reply_to_email_address(),
)
notification.reference = reference
update_notification(notification, provider)
@@ -174,12 +176,14 @@ def get_logo_url(base_url, logo_file):
def get_html_email_options(service):
govuk_banner = service.branding != BRANDING_ORG
govuk_banner = service.branding not in (BRANDING_ORG, BRANDING_ORG_BANNER)
brand_banner = service.branding == BRANDING_ORG_BANNER
if service.organisation and service.branding != BRANDING_GOVUK:
logo_url = get_logo_url(
current_app.config['ADMIN_BASE_URL'],
service.organisation.logo
)
) if service.organisation.logo else None
branding = {
'brand_colour': service.organisation.colour,
@@ -189,7 +193,7 @@ def get_html_email_options(service):
else:
branding = {}
return dict(govuk_banner=govuk_banner, **branding)
return dict(govuk_banner=govuk_banner, brand_banner=brand_banner, **branding)
def technical_failure(notification):

View File

@@ -1,3 +1,4 @@
import itertools
import time
import uuid
import datetime
@@ -123,6 +124,7 @@ user_to_service = db.Table(
BRANDING_GOVUK = 'govuk'
BRANDING_ORG = 'org'
BRANDING_BOTH = 'both'
BRANDING_ORG_BANNER = 'org_banner'
class BrandingTypes(db.Model):
@@ -134,7 +136,7 @@ class Organisation(db.Model):
__tablename__ = 'organisation'
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
colour = db.Column(db.String(7), nullable=True)
logo = db.Column(db.String(255), nullable=False)
logo = db.Column(db.String(255), nullable=True)
name = db.Column(db.String(255), nullable=True)
def serialize(self):
@@ -200,7 +202,7 @@ class Service(db.Model, Versioned):
email_from = db.Column(db.Text, index=False, unique=True, nullable=False)
created_by = db.relationship('User')
created_by_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), index=True, nullable=False)
reply_to_email_address = db.Column(db.Text, index=False, unique=False, nullable=True)
_reply_to_email_address = db.Column("reply_to_email_address", db.Text, index=False, unique=False, nullable=True)
letter_contact_block = db.Column(db.Text, index=False, unique=False, nullable=True)
sms_sender = db.Column(db.String(11), nullable=False, default=lambda: current_app.config['FROM_NUMBER'])
organisation_id = db.Column(UUID(as_uuid=True), db.ForeignKey('organisation.id'), index=True, nullable=True)
@@ -248,6 +250,21 @@ class Service(db.Model, Versioned):
else:
return self.sms_sender
def get_default_reply_to_email_address(self):
default_reply_to = [x for x in self.reply_to_email_addresses if x.is_default]
if len(default_reply_to) > 1:
raise Exception("There should only ever be one default")
else:
return default_reply_to[0].email_address if default_reply_to else None
def get_default_letter_contact(self):
default_letter_contact = [x for x in self.letter_contacts if x.is_default]
if len(default_letter_contact) > 1:
raise Exception("There should only ever be one default")
else:
return default_letter_contact[0].contact_block if default_letter_contact else \
self.letter_contact_block # need to update this to None after dropping the letter_contact_block column
class InboundNumber(db.Model):
__tablename__ = "inbound_numbers"
@@ -796,6 +813,9 @@ NOTIFICATION_STATUS_TYPES_NON_BILLABLE = list(set(NOTIFICATION_STATUS_TYPES) - s
NOTIFICATION_STATUS_TYPES_ENUM = db.Enum(*NOTIFICATION_STATUS_TYPES, name='notify_status_type')
NOTIFICATION_STATUS_LETTER_ACCEPTED = 'accepted'
NOTIFICATION_STATUS_LETTER_ACCEPTED_PRETTY = 'Accepted'
class NotificationStatusTypes(db.Model):
__tablename__ = 'notification_status_types'
@@ -899,10 +919,10 @@ class Notification(db.Model):
-
> IN
['failed', 'created']
['failed', 'created', 'accepted']
< OUT
['technical-failure', 'temporary-failure', 'permanent-failure', 'created']
['technical-failure', 'temporary-failure', 'permanent-failure', 'created', 'sending']
:param status_or_statuses: a single status or list of statuses
@@ -910,18 +930,17 @@ class Notification(db.Model):
"""
def _substitute_status_str(_status):
return NOTIFICATION_STATUS_TYPES_FAILED if _status == NOTIFICATION_FAILED else _status
return (
NOTIFICATION_STATUS_TYPES_FAILED if _status == NOTIFICATION_FAILED else
[NOTIFICATION_CREATED, NOTIFICATION_SENDING] if _status == NOTIFICATION_STATUS_LETTER_ACCEPTED else
[_status]
)
def _substitute_status_seq(_statuses):
if NOTIFICATION_FAILED in _statuses:
_statuses = list(set(
NOTIFICATION_STATUS_TYPES_FAILED + [_s for _s in _statuses if _s != NOTIFICATION_FAILED]
))
return _statuses
return list(set(itertools.chain.from_iterable(_substitute_status_str(status) for status in _statuses)))
if isinstance(status_or_statuses, str):
return _substitute_status_str(status_or_statuses)
return _substitute_status_seq(status_or_statuses)
@property
@@ -961,17 +980,29 @@ class Notification(db.Model):
'sent': 'Sent internationally'
},
'letter': {
'failed': 'Failed',
'technical-failure': 'Technical failure',
'temporary-failure': 'Temporary failure',
'permanent-failure': 'Permanent failure',
'delivered': 'Delivered',
'sending': 'Sending',
'created': 'Sending',
'sent': 'Delivered'
'sending': NOTIFICATION_STATUS_LETTER_ACCEPTED_PRETTY,
'created': NOTIFICATION_STATUS_LETTER_ACCEPTED_PRETTY,
}
}[self.template.template_type].get(self.status, self.status)
def get_letter_status(self):
"""
Return the notification_status, as we should present for letters. The distinction between created and sending is
a bit more confusing for letters, not to mention that there's no concept of temporary or permanent failure yet.
"""
# this should only ever be called for letter notifications - it makes no sense otherwise and I'd rather not
# get the two code flows mixed up at all
assert self.notification_type == LETTER_TYPE
if self.status == NOTIFICATION_CREATED or NOTIFICATION_SENDING:
return NOTIFICATION_STATUS_LETTER_ACCEPTED
else:
# Currently can only be technical-failure
return status
def serialize_for_csv(self):
created_at_in_bst = convert_utc_to_bst(self.created_at)
serialized = {
@@ -1006,7 +1037,7 @@ class Notification(db.Model):
"line_6": None,
"postcode": None,
"type": self.notification_type,
"status": self.status,
"status": self.get_letter_status() if self.notification_type == LETTER_TYPE else self.status,
"template": template_dict,
"body": self.content,
"subject": self.subject,
@@ -1351,8 +1382,34 @@ class ServiceEmailReplyTo(db.Model):
def serialize(self):
return {
'id': str(self.id),
'service_id': str(self.service_id),
'email_address': self.email_address,
'is_default': self.is_default,
'created_at': self.created_at,
'updated_at': self.updated_at
'created_at': self.created_at.strftime(DATETIME_FORMAT),
'updated_at': self.updated_at.strftime(DATETIME_FORMAT) if self.updated_at else None
}
class ServiceLetterContact(db.Model):
__tablename__ = "service_letter_contacts"
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
service_id = db.Column(UUID(as_uuid=True), db.ForeignKey('services.id'), unique=False, index=True, nullable=False)
service = db.relationship(Service, backref=db.backref("letter_contacts"))
contact_block = db.Column(db.Text, nullable=False, index=False, unique=False)
is_default = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
updated_at = db.Column(db.DateTime, nullable=True, onupdate=datetime.datetime.utcnow)
def serialize(self):
return {
'id': str(self.id),
'service_id': str(self.service_id),
'contact_block': self.contact_block,
'is_default': self.is_default,
'created_at': self.created_at.strftime(DATETIME_FORMAT),
'updated_at': self.updated_at.strftime(DATETIME_FORMAT) if self.updated_at else None
}

View File

@@ -7,7 +7,7 @@ post_create_organisation_schema = {
"name": {"type": ["string", "null"]},
"logo": {"type": ["string", "null"]}
},
"required": ["logo"]
"required": []
}
post_update_organisation_schema = {

View File

@@ -182,6 +182,7 @@ class ServiceSchema(BaseSchema):
dvla_organisation = field_for(models.Service, 'dvla_organisation')
permissions = fields.Method("service_permissions")
override_flag = False
reply_to_email_address = fields.Method(method_name="get_reply_to_email_address")
def get_free_sms_fragment_limit(selfs, service):
return service.free_sms_fragment_limit()
@@ -189,9 +190,12 @@ class ServiceSchema(BaseSchema):
def service_permissions(self, service):
return [p.permission for p in service.permissions]
def get_reply_to_email_address(self, service):
return service.get_default_reply_to_email_address()
class Meta:
model = models.Service
dump_only = ['free_sms_fragment_limit']
dump_only = ['free_sms_fragment_limit', 'reply_to_email_address']
exclude = (
'updated_at',
'created_at',
@@ -205,6 +209,7 @@ class ServiceSchema(BaseSchema):
'service_sms_senders',
'monthly_billing',
'reply_to_email_addresses',
'letter_contacts',
)
strict = True

View File

@@ -46,7 +46,8 @@ from app.dao.service_whitelist_dao import (
dao_add_and_commit_whitelisted_contacts,
dao_remove_service_whitelist
)
from app.dao.service_email_reply_to_dao import create_or_update_email_reply_to, dao_get_reply_to_by_service_id
from app.dao.service_email_reply_to_dao import create_or_update_email_reply_to, dao_get_reply_to_by_service_id, \
add_reply_to_email_address_for_service, update_reply_to_email_address, dao_get_reply_to_by_id
from app.dao.provider_statistics_dao import get_fragment_count
from app.dao.users_dao import get_user_by_id
from app.errors import (
@@ -57,6 +58,7 @@ from app.models import Service, ServiceInboundApi
from app.schema_validation import validate
from app.service import statistics
from app.service.service_inbound_api_schema import service_inbound_api, update_service_inbound_api_schema
from app.service.service_senders_schema import add_service_email_reply_to_request
from app.service.utils import get_whitelist_objects
from app.service.sender import send_notification_to_service_users
from app.service.send_notification import send_one_off_notification
@@ -543,6 +545,35 @@ def get_email_reply_to_addresses(service_id):
return jsonify([i.serialize() for i in result]), 200
@service_blueprint.route('/<uuid:service_id>/email-reply-to/<uuid:reply_to_id>', methods=["GET"])
def get_email_reply_to_address(service_id, reply_to_id):
result = dao_get_reply_to_by_id(service_id=service_id, reply_to_id=reply_to_id)
return jsonify(result.serialize()), 200
@service_blueprint.route('/<uuid:service_id>/email-reply-to', methods=['POST'])
def add_service_reply_to_email_address(service_id):
# validate the service exists, throws ResultNotFound exception.
dao_fetch_service_by_id(service_id)
form = validate(request.get_json(), add_service_email_reply_to_request)
new_reply_to = add_reply_to_email_address_for_service(service_id=service_id,
email_address=form['email_address'],
is_default=form.get('is_default', True))
return jsonify(data=new_reply_to.serialize()), 201
@service_blueprint.route('/<uuid:service_id>/email-reply-to/<uuid:reply_to_email_id>', methods=['POST'])
def update_service_reply_to_email_address(service_id, reply_to_email_id):
# validate the service exists, throws ResultNotFound exception.
dao_fetch_service_by_id(service_id)
form = validate(request.get_json(), add_service_email_reply_to_request)
new_reply_to = update_reply_to_email_address(service_id=service_id,
reply_to_id=reply_to_email_id,
email_address=form['email_address'],
is_default=form.get('is_default', True))
return jsonify(data=new_reply_to.serialize()), 200
@service_blueprint.route('/unique', methods=["GET"])
def is_service_name_unique():
name, email_from = check_request_args(request)

View File

@@ -0,0 +1,11 @@
add_service_email_reply_to_request = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "POST service email reply to address",
"type": "object",
"title": "Add new email reply to address for service",
"properties": {
"email_address": {"type": "string", "format": "email_address"},
"is_default": {"type": "boolean"}
},
"required": ["email_address", "is_default"]
}

View File

@@ -1,4 +1,4 @@
from app.models import NOTIFICATION_STATUS_TYPES, TEMPLATE_TYPES
from app.models import NOTIFICATION_STATUS_TYPES, NOTIFICATION_STATUS_LETTER_ACCEPTED, TEMPLATE_TYPES
from app.schema_validation.definitions import (uuid, personalisation, letter_personalisation)
@@ -59,7 +59,7 @@ get_notifications_request = {
"status": {
"type": "array",
"items": {
"enum": NOTIFICATION_STATUS_TYPES
"enum": NOTIFICATION_STATUS_TYPES + [NOTIFICATION_STATUS_LETTER_ACCEPTED]
}
},
"template_type": {