mirror of
https://github.com/GSA/notifications-api.git
synced 2026-07-18 13:40:31 -04:00
Merge branch 'master' of https://github.com/alphagov/notifications-api into vb-platform-admin-api
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ post_create_organisation_schema = {
|
||||
"name": {"type": ["string", "null"]},
|
||||
"logo": {"type": ["string", "null"]}
|
||||
},
|
||||
"required": ["logo"]
|
||||
"required": []
|
||||
}
|
||||
|
||||
post_update_organisation_schema = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
app/service/service_senders_schema.py
Normal file
11
app/service/service_senders_schema.py
Normal 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"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user