mirror of
https://github.com/GSA/notifications-api.git
synced 2026-09-11 02:23:32 -04:00
+18
-3
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import request, url_for, g
|
from flask import request, url_for, g, jsonify
|
||||||
from flask import Flask, _request_ctx_stack
|
from flask import Flask, _request_ctx_stack
|
||||||
from flask.ext.sqlalchemy import SQLAlchemy
|
from flask.ext.sqlalchemy import SQLAlchemy
|
||||||
from flask_marshmallow import Marshmallow
|
from flask_marshmallow import Marshmallow
|
||||||
@@ -57,6 +57,13 @@ def create_app(app_name=None):
|
|||||||
encryption.init_app(application)
|
encryption.init_app(application)
|
||||||
clients.init_app(sms_clients=[firetext_client, mmg_client, loadtest_client], email_clients=[aws_ses_client])
|
clients.init_app(sms_clients=[firetext_client, mmg_client, loadtest_client], email_clients=[aws_ses_client])
|
||||||
|
|
||||||
|
register_blueprint(application)
|
||||||
|
register_v2_blueprints(application)
|
||||||
|
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def register_blueprint(application):
|
||||||
from app.service.rest import service_blueprint
|
from app.service.rest import service_blueprint
|
||||||
from app.user.rest import user as user_blueprint
|
from app.user.rest import user as user_blueprint
|
||||||
from app.template.rest import template as template_blueprint
|
from app.template.rest import template as template_blueprint
|
||||||
@@ -81,14 +88,16 @@ def create_app(app_name=None):
|
|||||||
application.register_blueprint(invite_blueprint)
|
application.register_blueprint(invite_blueprint)
|
||||||
application.register_blueprint(delivery_blueprint)
|
application.register_blueprint(delivery_blueprint)
|
||||||
application.register_blueprint(accept_invite, url_prefix='/invite')
|
application.register_blueprint(accept_invite, url_prefix='/invite')
|
||||||
|
|
||||||
application.register_blueprint(template_statistics_blueprint)
|
application.register_blueprint(template_statistics_blueprint)
|
||||||
application.register_blueprint(events_blueprint)
|
application.register_blueprint(events_blueprint)
|
||||||
application.register_blueprint(provider_details_blueprint, url_prefix='/provider-details')
|
application.register_blueprint(provider_details_blueprint, url_prefix='/provider-details')
|
||||||
application.register_blueprint(spec_blueprint, url_prefix='/spec')
|
application.register_blueprint(spec_blueprint, url_prefix='/spec')
|
||||||
application.register_blueprint(organisation_blueprint, url_prefix='/organisation')
|
application.register_blueprint(organisation_blueprint, url_prefix='/organisation')
|
||||||
|
|
||||||
return application
|
|
||||||
|
def register_v2_blueprints(application):
|
||||||
|
from app.v2.notifications.post_notifications import notification_blueprint
|
||||||
|
application.register_blueprint(notification_blueprint)
|
||||||
|
|
||||||
|
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
@@ -120,6 +129,12 @@ def init_app(app):
|
|||||||
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
|
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def page_not_found(e):
|
||||||
|
msg = e.description or "Not found"
|
||||||
|
app.logger.exception(msg)
|
||||||
|
return jsonify(result='error', message=msg), 404
|
||||||
|
|
||||||
|
|
||||||
def create_uuid():
|
def create_uuid():
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ from app.dao.services_dao import dao_fetch_service_by_id
|
|||||||
class AuthError(Exception):
|
class AuthError(Exception):
|
||||||
def __init__(self, message, code):
|
def __init__(self, message, code):
|
||||||
self.message = {"token": [message]}
|
self.message = {"token": [message]}
|
||||||
|
self.short_message = message
|
||||||
self.code = code
|
self.code = code
|
||||||
|
|
||||||
|
def to_dict_v2(self):
|
||||||
|
return {'code': self.code,
|
||||||
|
'message': self.short_message,
|
||||||
|
'fields': self.message,
|
||||||
|
'link': 'link to docs'}
|
||||||
|
|
||||||
|
|
||||||
def get_auth_token(req):
|
def get_auth_token(req):
|
||||||
auth_header = req.headers.get('Authorization', None)
|
auth_header = req.headers.get('Authorization', None)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytz
|
import pytz
|
||||||
from datetime import (
|
from datetime import (
|
||||||
datetime,
|
datetime,
|
||||||
@@ -12,7 +11,7 @@ from werkzeug.datastructures import MultiDict
|
|||||||
from sqlalchemy import (desc, func, or_, and_, asc, cast, Text)
|
from sqlalchemy import (desc, func, or_, and_, asc, cast, Text)
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app import db
|
from app import db, create_uuid
|
||||||
from app.dao import days_ago
|
from app.dao import days_ago
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Service,
|
Service,
|
||||||
@@ -125,7 +124,7 @@ def dao_get_last_template_usage(template_id):
|
|||||||
def dao_create_notification(notification):
|
def dao_create_notification(notification):
|
||||||
if not notification.id:
|
if not notification.id:
|
||||||
# need to populate defaulted fields before we create the notification history object
|
# need to populate defaulted fields before we create the notification history object
|
||||||
notification.id = uuid.uuid4()
|
notification.id = create_uuid()
|
||||||
if not notification.status:
|
if not notification.status:
|
||||||
notification.status = 'created'
|
notification.status = 'created'
|
||||||
|
|
||||||
|
|||||||
+25
-17
@@ -9,6 +9,9 @@ from app.authentication.auth import AuthError
|
|||||||
|
|
||||||
|
|
||||||
class InvalidRequest(Exception):
|
class InvalidRequest(Exception):
|
||||||
|
code = None
|
||||||
|
link = None
|
||||||
|
fields = []
|
||||||
|
|
||||||
def __init__(self, message, status_code):
|
def __init__(self, message, status_code):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -18,62 +21,67 @@ class InvalidRequest(Exception):
|
|||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {'result': 'error', 'message': self.message}
|
return {'result': 'error', 'message': self.message}
|
||||||
|
|
||||||
|
def to_dict_v2(self):
|
||||||
|
'''
|
||||||
|
Version 2 of the public api error response.
|
||||||
|
'''
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
"link": self.link,
|
||||||
|
"fields": self.fields
|
||||||
|
}
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.to_dict())
|
return str(self.to_dict())
|
||||||
|
|
||||||
|
|
||||||
def register_errors(blueprint):
|
def register_errors(blueprint):
|
||||||
|
|
||||||
@blueprint.app_errorhandler(AuthError)
|
@blueprint.errorhandler(AuthError)
|
||||||
def authentication_error(error):
|
def authentication_error(error):
|
||||||
return jsonify(result='error', message=error.message), error.code
|
return jsonify(result='error', message=error.message), error.code
|
||||||
|
|
||||||
@blueprint.app_errorhandler(ValidationError)
|
@blueprint.errorhandler(ValidationError)
|
||||||
def validation_error(error):
|
def validation_error(error):
|
||||||
current_app.logger.error(error)
|
current_app.logger.error(error)
|
||||||
return jsonify(result='error', message=error.messages), 400
|
return jsonify(result='error', message=error.messages), 400
|
||||||
|
|
||||||
@blueprint.app_errorhandler(InvalidRequest)
|
@blueprint.errorhandler(InvalidRequest)
|
||||||
def invalid_data(error):
|
def invalid_data(error):
|
||||||
response = jsonify(error.to_dict())
|
response = jsonify(error.to_dict())
|
||||||
response.status_code = error.status_code
|
response.status_code = error.status_code
|
||||||
current_app.logger.error(error)
|
current_app.logger.error(error)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@blueprint.app_errorhandler(400)
|
@blueprint.errorhandler(400)
|
||||||
def bad_request(e):
|
def bad_request(e):
|
||||||
msg = e.description or "Invalid request parameters"
|
msg = e.description or "Invalid request parameters"
|
||||||
current_app.logger.exception(msg)
|
current_app.logger.exception(msg)
|
||||||
return jsonify(result='error', message=str(msg)), 400
|
return jsonify(result='error', message=str(msg)), 400
|
||||||
|
|
||||||
@blueprint.app_errorhandler(401)
|
@blueprint.errorhandler(401)
|
||||||
def unauthorized(e):
|
def unauthorized(e):
|
||||||
error_message = "Unauthorized, authentication token must be provided"
|
error_message = "Unauthorized, authentication token must be provided"
|
||||||
return jsonify(result='error', message=error_message), 401, [('WWW-Authenticate', 'Bearer')]
|
return jsonify(result='error', message=error_message), 401, [('WWW-Authenticate', 'Bearer')]
|
||||||
|
|
||||||
@blueprint.app_errorhandler(403)
|
@blueprint.errorhandler(403)
|
||||||
def forbidden(e):
|
def forbidden(e):
|
||||||
error_message = "Forbidden, invalid authentication token provided"
|
error_message = "Forbidden, invalid authentication token provided"
|
||||||
return jsonify(result='error', message=error_message), 403
|
return jsonify(result='error', message=error_message), 403
|
||||||
|
|
||||||
@blueprint.app_errorhandler(404)
|
@blueprint.errorhandler(429)
|
||||||
def page_not_found(e):
|
|
||||||
msg = e.description or "Not found"
|
|
||||||
current_app.logger.exception(msg)
|
|
||||||
return jsonify(result='error', message=msg), 404
|
|
||||||
|
|
||||||
@blueprint.app_errorhandler(429)
|
|
||||||
def limit_exceeded(e):
|
def limit_exceeded(e):
|
||||||
current_app.logger.exception(e)
|
current_app.logger.exception(e)
|
||||||
return jsonify(result='error', message=str(e.description)), 429
|
return jsonify(result='error', message=str(e.description)), 429
|
||||||
|
|
||||||
@blueprint.app_errorhandler(NoResultFound)
|
@blueprint.errorhandler(NoResultFound)
|
||||||
@blueprint.app_errorhandler(DataError)
|
@blueprint.errorhandler(DataError)
|
||||||
def no_result_found(e):
|
def no_result_found(e):
|
||||||
current_app.logger.exception(e)
|
current_app.logger.exception(e)
|
||||||
return jsonify(result='error', message="No result found"), 404
|
return jsonify(result='error', message="No result found"), 404
|
||||||
|
|
||||||
@blueprint.app_errorhandler(SQLAlchemyError)
|
@blueprint.errorhandler(SQLAlchemyError)
|
||||||
def db_error(e):
|
def db_error(e):
|
||||||
current_app.logger.exception(e)
|
current_app.logger.exception(e)
|
||||||
if e.orig.pgerror and \
|
if e.orig.pgerror and \
|
||||||
@@ -89,7 +97,7 @@ def register_errors(blueprint):
|
|||||||
|
|
||||||
# this must be defined after all other error handlers since it catches the generic Exception object
|
# this must be defined after all other error handlers since it catches the generic Exception object
|
||||||
@blueprint.app_errorhandler(500)
|
@blueprint.app_errorhandler(500)
|
||||||
@blueprint.app_errorhandler(Exception)
|
@blueprint.errorhandler(Exception)
|
||||||
def internal_server_error(e):
|
def internal_server_error(e):
|
||||||
current_app.logger.exception(e)
|
current_app.logger.exception(e)
|
||||||
return jsonify(result='error', message="Internal server error"), 500
|
return jsonify(result='error', message="Internal server error"), 500
|
||||||
|
|||||||
@@ -563,6 +563,29 @@ class Notification(db.Model):
|
|||||||
key_type=key_type
|
key_type=key_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_v2_api_request(cls,
|
||||||
|
template_id,
|
||||||
|
template_version,
|
||||||
|
recipient,
|
||||||
|
service_id,
|
||||||
|
personalisation,
|
||||||
|
notification_type,
|
||||||
|
api_key_id,
|
||||||
|
key_type):
|
||||||
|
return cls(
|
||||||
|
template_id=template_id,
|
||||||
|
template_version=template_version,
|
||||||
|
to=recipient,
|
||||||
|
service_id=service_id,
|
||||||
|
status='created',
|
||||||
|
created_at=datetime.datetime.utcnow(),
|
||||||
|
personalisation=personalisation,
|
||||||
|
notification_type=notification_type,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
key_type=key_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotificationHistory(db.Model):
|
class NotificationHistory(db.Model):
|
||||||
__tablename__ = 'notification_history'
|
__tablename__ = 'notification_history'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from flask import current_app
|
||||||
|
from notifications_utils.renderers import PassThrough
|
||||||
|
from notifications_utils.template import Template
|
||||||
|
|
||||||
|
from app.celery import provider_tasks
|
||||||
|
from app.dao.notifications_dao import dao_create_notification, dao_delete_notifications_and_history_by_id
|
||||||
|
from app.models import SMS_TYPE, Notification, KEY_TYPE_TEST, EMAIL_TYPE
|
||||||
|
from app.notifications.validators import check_sms_content_char_count
|
||||||
|
from app.v2.errors import BadRequestError
|
||||||
|
|
||||||
|
|
||||||
|
def create_content_for_notification(template, personalisation):
|
||||||
|
template_object = Template(
|
||||||
|
template.__dict__,
|
||||||
|
personalisation,
|
||||||
|
renderer=PassThrough()
|
||||||
|
)
|
||||||
|
check_placeholders(template_object)
|
||||||
|
|
||||||
|
if template_object.template_type == SMS_TYPE:
|
||||||
|
check_sms_content_char_count(template_object.replaced_content_count)
|
||||||
|
return template_object
|
||||||
|
|
||||||
|
|
||||||
|
def check_placeholders(template_object):
|
||||||
|
if template_object.missing_data:
|
||||||
|
message = 'Template missing personalisation: {}'.format(", ".join(template_object.missing_data))
|
||||||
|
raise BadRequestError(fields=[{'template': message}], message=message)
|
||||||
|
|
||||||
|
if template_object.additional_data:
|
||||||
|
message = 'Template personalisation not needed for template: {}'.format(
|
||||||
|
", ".join(template_object.additional_data))
|
||||||
|
raise BadRequestError(fields=[{'template': message}], message=message)
|
||||||
|
|
||||||
|
|
||||||
|
def persist_notification(template_id,
|
||||||
|
template_version,
|
||||||
|
recipient,
|
||||||
|
service_id,
|
||||||
|
personalisation,
|
||||||
|
notification_type,
|
||||||
|
api_key_id,
|
||||||
|
key_type):
|
||||||
|
notification = Notification.from_v2_api_request(template_id=template_id,
|
||||||
|
template_version=template_version,
|
||||||
|
recipient=recipient,
|
||||||
|
service_id=service_id,
|
||||||
|
personalisation=personalisation,
|
||||||
|
notification_type=notification_type,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
key_type=key_type)
|
||||||
|
dao_create_notification(notification)
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification_to_queue(notification, research_mode):
|
||||||
|
try:
|
||||||
|
research_mode = research_mode or notification.key_type == KEY_TYPE_TEST
|
||||||
|
if notification.notification_type == SMS_TYPE:
|
||||||
|
provider_tasks.deliver_sms.apply_async(
|
||||||
|
[str(notification.id)],
|
||||||
|
queue='send-sms' if not research_mode else 'research-mode'
|
||||||
|
)
|
||||||
|
if notification.notification_type == EMAIL_TYPE:
|
||||||
|
provider_tasks.deliver_email.apply_async(
|
||||||
|
[str(notification.id)],
|
||||||
|
queue='send-email' if not research_mode else 'research-mode'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.exception("Failed to send to SQS exception")
|
||||||
|
dao_delete_notifications_and_history_by_id(notification.id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
current_app.logger.info(
|
||||||
|
"{} {} created at {}".format(notification.notification_type, notification.id, notification.created_at)
|
||||||
|
)
|
||||||
+27
-75
@@ -7,33 +7,34 @@ from flask import (
|
|||||||
current_app,
|
current_app,
|
||||||
json
|
json
|
||||||
)
|
)
|
||||||
|
|
||||||
from notifications_utils.template import Template
|
|
||||||
from notifications_utils.renderers import PassThrough
|
from notifications_utils.renderers import PassThrough
|
||||||
|
from notifications_utils.template import Template
|
||||||
|
|
||||||
|
from app import api_user, create_uuid, statsd_client
|
||||||
from app.clients.email.aws_ses import get_aws_responses
|
from app.clients.email.aws_ses import get_aws_responses
|
||||||
from app import api_user, create_uuid, DATETIME_FORMAT, statsd_client
|
|
||||||
from app.dao.notifications_dao import dao_create_notification, dao_delete_notifications_and_history_by_id
|
|
||||||
from app.models import KEY_TYPE_TEAM, KEY_TYPE_TEST, Notification, KEY_TYPE_NORMAL, EMAIL_TYPE
|
|
||||||
from app.dao import (
|
from app.dao import (
|
||||||
templates_dao,
|
templates_dao,
|
||||||
services_dao,
|
services_dao,
|
||||||
notifications_dao
|
notifications_dao
|
||||||
)
|
)
|
||||||
|
from app.models import KEY_TYPE_TEAM
|
||||||
from app.models import SMS_TYPE
|
from app.models import SMS_TYPE
|
||||||
from app.notifications.process_client_response import (
|
from app.notifications.process_client_response import (
|
||||||
validate_callback_data,
|
validate_callback_data,
|
||||||
process_sms_client_response
|
process_sms_client_response
|
||||||
)
|
)
|
||||||
from app.service.utils import service_allowed_to_send_to
|
from app.notifications.process_notifications import persist_notification, send_notification_to_queue
|
||||||
|
from app.notifications.validators import check_service_message_limit, check_template_is_for_notification_type, \
|
||||||
|
check_template_is_active
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
email_notification_schema,
|
email_notification_schema,
|
||||||
sms_template_notification_schema,
|
sms_template_notification_schema,
|
||||||
notification_with_personalisation_schema,
|
notification_with_personalisation_schema,
|
||||||
notifications_filter_schema,
|
notifications_filter_schema,
|
||||||
notifications_statistics_schema,
|
notifications_statistics_schema,
|
||||||
day_schema,
|
day_schema
|
||||||
unarchived_template_schema
|
|
||||||
)
|
)
|
||||||
|
from app.service.utils import service_allowed_to_send_to
|
||||||
from app.utils import pagination_links
|
from app.utils import pagination_links
|
||||||
|
|
||||||
notifications = Blueprint('notifications', __name__)
|
notifications = Blueprint('notifications', __name__)
|
||||||
@@ -44,7 +45,6 @@ from app.errors import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
register_errors(notifications)
|
register_errors(notifications)
|
||||||
from app.celery import provider_tasks
|
|
||||||
|
|
||||||
|
|
||||||
@notifications.route('/notifications/email/ses', methods=['POST'])
|
@notifications.route('/notifications/email/ses', methods=['POST'])
|
||||||
@@ -216,43 +216,32 @@ def send_notification(notification_type):
|
|||||||
if errors:
|
if errors:
|
||||||
raise InvalidRequest(errors, status_code=400)
|
raise InvalidRequest(errors, status_code=400)
|
||||||
|
|
||||||
if all((api_user.key_type != KEY_TYPE_TEST,
|
check_service_message_limit(api_user.key_type, service)
|
||||||
service.restricted)):
|
|
||||||
service_stats = services_dao.fetch_todays_total_message_count(service.id)
|
|
||||||
if service_stats >= service.message_limit:
|
|
||||||
error = 'Exceeded send limits ({}) for today'.format(service.message_limit)
|
|
||||||
raise InvalidRequest(error, status_code=429)
|
|
||||||
|
|
||||||
template = templates_dao.dao_get_template_by_id_and_service_id(
|
template = templates_dao.dao_get_template_by_id_and_service_id(template_id=notification['template'],
|
||||||
template_id=notification['template'],
|
service_id=service_id)
|
||||||
service_id=service_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if notification_type != template.template_type:
|
check_template_is_for_notification_type(notification_type, template.template_type)
|
||||||
raise InvalidRequest("{0} template is not suitable for {1} notification".format(template.template_type,
|
check_template_is_active(template)
|
||||||
notification_type),
|
|
||||||
status_code=400)
|
|
||||||
|
|
||||||
errors = unarchived_template_schema.validate({'archived': template.archived})
|
|
||||||
if errors:
|
|
||||||
raise InvalidRequest(errors, status_code=400)
|
|
||||||
|
|
||||||
template_object = create_template_object_for_notification(template, notification.get('personalisation', {}))
|
template_object = create_template_object_for_notification(template, notification.get('personalisation', {}))
|
||||||
|
|
||||||
_service_allowed_to_send_to(notification, service)
|
_service_allowed_to_send_to(notification, service)
|
||||||
|
|
||||||
notification_id = create_uuid()
|
saved_notification = None
|
||||||
notification.update({"template_version": template.version})
|
|
||||||
if not _simulated_recipient(notification['to'], notification_type):
|
if not _simulated_recipient(notification['to'], notification_type):
|
||||||
persist_notification(
|
saved_notification = persist_notification(template_id=template.id,
|
||||||
service,
|
template_version=template.version,
|
||||||
notification_id,
|
recipient=notification['to'],
|
||||||
notification,
|
service_id=service.id,
|
||||||
datetime.utcnow().strftime(DATETIME_FORMAT),
|
personalisation=notification.get('personalisation', None),
|
||||||
notification_type,
|
notification_type=notification_type,
|
||||||
str(api_user.id),
|
api_key_id=api_user.id,
|
||||||
api_user.key_type
|
key_type=api_user.key_type)
|
||||||
)
|
send_notification_to_queue(saved_notification, service.research_mode)
|
||||||
|
|
||||||
|
notification_id = create_uuid() if saved_notification is None else saved_notification.id
|
||||||
|
notification.update({"template_version": template.version})
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
data=get_notification_return_data(
|
data=get_notification_return_data(
|
||||||
@@ -281,43 +270,6 @@ def _simulated_recipient(to_address, notification_type):
|
|||||||
else to_address in current_app.config['SIMULATED_EMAIL_ADDRESSES'])
|
else to_address in current_app.config['SIMULATED_EMAIL_ADDRESSES'])
|
||||||
|
|
||||||
|
|
||||||
def persist_notification(
|
|
||||||
service,
|
|
||||||
notification_id,
|
|
||||||
notification,
|
|
||||||
created_at,
|
|
||||||
notification_type,
|
|
||||||
api_key_id=None,
|
|
||||||
key_type=KEY_TYPE_NORMAL,
|
|
||||||
):
|
|
||||||
dao_create_notification(
|
|
||||||
Notification.from_api_request(
|
|
||||||
created_at, notification, notification_id, service.id, notification_type, api_key_id, key_type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
research_mode = service.research_mode or key_type == KEY_TYPE_TEST
|
|
||||||
if notification_type == SMS_TYPE:
|
|
||||||
provider_tasks.deliver_sms.apply_async(
|
|
||||||
[str(notification_id)],
|
|
||||||
queue='send-sms' if not research_mode else 'research-mode'
|
|
||||||
)
|
|
||||||
if notification_type == EMAIL_TYPE:
|
|
||||||
provider_tasks.deliver_email.apply_async(
|
|
||||||
[str(notification_id)],
|
|
||||||
queue='send-email' if not research_mode else 'research-mode'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.exception("Failed to send to SQS exception")
|
|
||||||
dao_delete_notifications_and_history_by_id(notification_id)
|
|
||||||
raise InvalidRequest(message="Internal server error", status_code=500)
|
|
||||||
|
|
||||||
current_app.logger.info(
|
|
||||||
"{} {} created at {}".format(notification_type, notification_id, created_at)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _service_allowed_to_send_to(notification, service):
|
def _service_allowed_to_send_to(notification, service):
|
||||||
if not service_allowed_to_send_to(notification['to'], service, api_user.key_type):
|
if not service_allowed_to_send_to(notification['to'], service, api_user.key_type):
|
||||||
if api_user.key_type == KEY_TYPE_TEAM:
|
if api_user.key_type == KEY_TYPE_TEAM:
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from app.dao import services_dao
|
||||||
|
from app.models import KEY_TYPE_TEST, KEY_TYPE_TEAM
|
||||||
|
from app.service.utils import service_allowed_to_send_to
|
||||||
|
from app.v2.errors import TooManyRequestsError, BadRequestError
|
||||||
|
|
||||||
|
|
||||||
|
def check_service_message_limit(key_type, service):
|
||||||
|
if all((key_type != KEY_TYPE_TEST,
|
||||||
|
service.restricted)):
|
||||||
|
service_stats = services_dao.fetch_todays_total_message_count(service.id)
|
||||||
|
if service_stats >= service.message_limit:
|
||||||
|
raise TooManyRequestsError(service.message_limit)
|
||||||
|
|
||||||
|
|
||||||
|
def check_template_is_for_notification_type(notification_type, template_type):
|
||||||
|
if notification_type != template_type:
|
||||||
|
message = "{0} template is not suitable for {1} notification".format(template_type,
|
||||||
|
notification_type)
|
||||||
|
raise BadRequestError(fields=[{'template': message}], message=message)
|
||||||
|
|
||||||
|
|
||||||
|
def check_template_is_active(template):
|
||||||
|
if template.archived:
|
||||||
|
raise BadRequestError(fields=[{'template': 'Template has been deleted'}],
|
||||||
|
message="Template has been deleted")
|
||||||
|
|
||||||
|
|
||||||
|
def service_can_send_to_recipient(send_to, key_type, service):
|
||||||
|
if not service_allowed_to_send_to(send_to, service, key_type):
|
||||||
|
if key_type == KEY_TYPE_TEAM:
|
||||||
|
message = 'Can’t send to this recipient using a team-only API key'
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
'Can’t send to this recipient when service is in trial mode '
|
||||||
|
'– see https://www.notifications.service.gov.uk/trial-mode'
|
||||||
|
)
|
||||||
|
raise BadRequestError(message=message)
|
||||||
|
|
||||||
|
|
||||||
|
def check_sms_content_char_count(content_count):
|
||||||
|
char_count_limit = current_app.config.get('SMS_CHAR_COUNT_LIMIT')
|
||||||
|
if (
|
||||||
|
content_count > char_count_limit
|
||||||
|
):
|
||||||
|
message = 'Content for template has a character count greater than the limit of {}'.format(char_count_limit)
|
||||||
|
raise BadRequestError(message=message)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import json
|
||||||
|
from jsonschema import Draft4Validator, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def validate(json_to_validate, schema):
|
||||||
|
validator = Draft4Validator(schema)
|
||||||
|
errors = list(validator.iter_errors(json_to_validate))
|
||||||
|
if errors.__len__() > 0:
|
||||||
|
raise ValidationError(build_error_message(errors, schema))
|
||||||
|
return json_to_validate
|
||||||
|
|
||||||
|
|
||||||
|
def build_error_message(errors, schema):
|
||||||
|
fields = []
|
||||||
|
for e in errors:
|
||||||
|
field = "'{}' {}".format(e.path[0], e.schema.get('validationMessage')) if e.schema.get(
|
||||||
|
'validationMessage') else e.message
|
||||||
|
s = field.split("'")
|
||||||
|
field = {s[1]: s[2].strip()}
|
||||||
|
fields.append(field)
|
||||||
|
message = {
|
||||||
|
"code": "1001",
|
||||||
|
"message": "Validation error occurred - {}".format(schema['title']),
|
||||||
|
"link": "link to error documentation (not yet implemented)",
|
||||||
|
"fields": fields
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(message)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Definitions are intended for schema definitions that are not likely to change from version to version.
|
||||||
|
If the definition is specific to a version put it in a definition file in the version package
|
||||||
|
"""
|
||||||
|
|
||||||
|
uuid = {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$",
|
||||||
|
"validationMessage": "not a valid UUID",
|
||||||
|
"code": "1001", # yet to be implemented
|
||||||
|
"link": "link to our error documentation not yet implemented"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
personalisation = {
|
||||||
|
"type": "object",
|
||||||
|
"validationMessage": "should contain key value pairs",
|
||||||
|
"code": "1001", # yet to be implemented
|
||||||
|
"link": "link to our error documentation not yet implemented"
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from flask import jsonify, current_app
|
||||||
|
from jsonschema import ValidationError
|
||||||
|
from sqlalchemy.exc import DataError
|
||||||
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
|
|
||||||
|
from app.authentication.auth import AuthError
|
||||||
|
from app.errors import InvalidRequest
|
||||||
|
|
||||||
|
|
||||||
|
class TooManyRequestsError(InvalidRequest):
|
||||||
|
status_code = 429
|
||||||
|
# code and link will be in a static file
|
||||||
|
code = "10429"
|
||||||
|
link = "link to docs"
|
||||||
|
message_template = 'Exceeded send limits ({}) for today'
|
||||||
|
|
||||||
|
def __init__(self, sending_limit):
|
||||||
|
self.message = self.message_template.format(sending_limit)
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(InvalidRequest):
|
||||||
|
status_code = 400
|
||||||
|
code = 10400
|
||||||
|
link = "link to documentation"
|
||||||
|
message = "An error occurred"
|
||||||
|
|
||||||
|
def __init__(self, fields=[], message=None):
|
||||||
|
self.fields = fields
|
||||||
|
self.message = message if message else self.message
|
||||||
|
|
||||||
|
|
||||||
|
def register_errors(blueprint):
|
||||||
|
@blueprint.errorhandler(InvalidRequest)
|
||||||
|
def invalid_data(error):
|
||||||
|
current_app.logger.error(error)
|
||||||
|
response = jsonify(error.to_dict_v2()), error.status_code
|
||||||
|
return response
|
||||||
|
|
||||||
|
@blueprint.errorhandler(ValidationError)
|
||||||
|
def validation_error(error):
|
||||||
|
current_app.logger.exception(error)
|
||||||
|
return jsonify(json.loads(error.message)), 400
|
||||||
|
|
||||||
|
@blueprint.errorhandler(NoResultFound)
|
||||||
|
@blueprint.errorhandler(DataError)
|
||||||
|
def no_result_found(e):
|
||||||
|
current_app.logger.exception(e)
|
||||||
|
return jsonify(message="No result found"), 404
|
||||||
|
|
||||||
|
@blueprint.errorhandler(AuthError)
|
||||||
|
def auth_error(error):
|
||||||
|
return jsonify(error.to_dict_v2()), error.code
|
||||||
|
|
||||||
|
@blueprint.errorhandler(Exception)
|
||||||
|
def internal_server_error(error):
|
||||||
|
current_app.logger.exception(error)
|
||||||
|
return jsonify(message='Internal server error'), 500
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from app.v2.errors import register_errors
|
||||||
|
|
||||||
|
notification_blueprint = Blueprint("v2_notifications", __name__, url_prefix='/v2/notifications')
|
||||||
|
|
||||||
|
register_errors(notification_blueprint)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from app.v2.notifications import notification_blueprint
|
||||||
|
|
||||||
|
|
||||||
|
@notification_blueprint.route("/<uuid:id>", methods=['GET'])
|
||||||
|
def get_notification_by_id(id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@notification_blueprint.route("/", methods=['GET'])
|
||||||
|
def get_notifications():
|
||||||
|
# validate notifications request arguments
|
||||||
|
# fetch all notifications
|
||||||
|
# return notifications_response schema
|
||||||
|
pass
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from app.schema_validation.definitions import (uuid, personalisation)
|
||||||
|
|
||||||
|
post_sms_request = {
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||||
|
"description": "POST sms notification schema",
|
||||||
|
"type": "object",
|
||||||
|
"title": "POST v2/notifications/sms",
|
||||||
|
"properties": {
|
||||||
|
"reference": {"type": "string"},
|
||||||
|
"phone_number": {"type": "string", "format": "sms"},
|
||||||
|
"template_id": uuid,
|
||||||
|
"personalisation": personalisation
|
||||||
|
},
|
||||||
|
"required": ["phone_number", "template_id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||||
|
"description": "POST sms notification response schema",
|
||||||
|
"type": "object",
|
||||||
|
"title": "notification content",
|
||||||
|
"properties": {
|
||||||
|
"body": {"type": "string"},
|
||||||
|
"from_number": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["body"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# this may belong in a templates module
|
||||||
|
template = {
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||||
|
"description": "POST sms notification response schema",
|
||||||
|
"type": "object",
|
||||||
|
"title": "notification content",
|
||||||
|
"properties": {
|
||||||
|
"id": uuid,
|
||||||
|
"version": {"type": "integer"},
|
||||||
|
"uri": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["id", "version", "uri"]
|
||||||
|
}
|
||||||
|
|
||||||
|
post_sms_response = {
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||||
|
"description": "POST sms notification response schema",
|
||||||
|
"type": "object",
|
||||||
|
"title": "response v2/notifications/sms",
|
||||||
|
"properties": {
|
||||||
|
"id": uuid,
|
||||||
|
"reference": {"type": "string"},
|
||||||
|
"content": content,
|
||||||
|
"uri": {"type": "string"},
|
||||||
|
"template": template
|
||||||
|
},
|
||||||
|
"required": ["id", "content", "uri", "template"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_post_sms_response_from_notification(notification, body, from_number, url_root):
|
||||||
|
return {"id": notification.id,
|
||||||
|
"reference": None, # not yet implemented
|
||||||
|
"content": {'body': body,
|
||||||
|
'from_number': from_number},
|
||||||
|
"uri": "{}/v2/notifications/{}".format(url_root, str(notification.id)),
|
||||||
|
"template": {"id": notification.template_id,
|
||||||
|
"version": notification.template_version,
|
||||||
|
"uri": "{}/v2/templates/{}".format(url_root, str(notification.template_id))}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from flask import request, jsonify, current_app
|
||||||
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
|
|
||||||
|
from app import api_user
|
||||||
|
from app.dao import services_dao, templates_dao
|
||||||
|
from app.models import SMS_TYPE
|
||||||
|
from app.notifications.process_notifications import (create_content_for_notification,
|
||||||
|
persist_notification,
|
||||||
|
send_notification_to_queue)
|
||||||
|
from app.notifications.validators import (check_service_message_limit,
|
||||||
|
check_template_is_for_notification_type,
|
||||||
|
check_template_is_active,
|
||||||
|
service_can_send_to_recipient,
|
||||||
|
check_sms_content_char_count)
|
||||||
|
from app.schema_validation import validate
|
||||||
|
from app.v2.errors import BadRequestError
|
||||||
|
from app.v2.notifications import notification_blueprint
|
||||||
|
from app.v2.notifications.notification_schemas import (post_sms_request,
|
||||||
|
create_post_sms_response_from_notification)
|
||||||
|
|
||||||
|
|
||||||
|
@notification_blueprint.route('/sms', methods=['POST'])
|
||||||
|
def post_sms_notification():
|
||||||
|
form = validate(request.get_json(), post_sms_request)
|
||||||
|
service = services_dao.dao_fetch_service_by_id(api_user.service_id)
|
||||||
|
|
||||||
|
check_service_message_limit(api_user.key_type, service)
|
||||||
|
service_can_send_to_recipient(form['phone_number'], api_user.key_type, service)
|
||||||
|
|
||||||
|
template, content = __validate_template(form, service)
|
||||||
|
|
||||||
|
notification = persist_notification(template_id=template.id,
|
||||||
|
template_version=template.version,
|
||||||
|
recipient=form['phone_number'],
|
||||||
|
service_id=service.id,
|
||||||
|
personalisation=form.get('personalisation', None),
|
||||||
|
notification_type=SMS_TYPE,
|
||||||
|
api_key_id=api_user.id,
|
||||||
|
key_type=api_user.key_type)
|
||||||
|
send_notification_to_queue(notification, service.research_mode)
|
||||||
|
|
||||||
|
resp = create_post_sms_response_from_notification(notification, content, service.sms_sender, request.url_root)
|
||||||
|
return jsonify(resp), 201
|
||||||
|
|
||||||
|
|
||||||
|
@notification_blueprint.route('/email', methods=['POST'])
|
||||||
|
def post_email_notification():
|
||||||
|
# validate post form against post_email_request schema
|
||||||
|
# validate service
|
||||||
|
# validate template
|
||||||
|
# persist notification
|
||||||
|
# send notification to queue
|
||||||
|
# create content
|
||||||
|
# return post_email_response schema
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def __validate_template(form, service):
|
||||||
|
try:
|
||||||
|
template = templates_dao.dao_get_template_by_id_and_service_id(template_id=form['template_id'],
|
||||||
|
service_id=service.id)
|
||||||
|
except NoResultFound:
|
||||||
|
message = 'Template not found'
|
||||||
|
raise BadRequestError(message=message,
|
||||||
|
fields=[{'template': message}])
|
||||||
|
|
||||||
|
check_template_is_for_notification_type(SMS_TYPE, template.template_type)
|
||||||
|
check_template_is_active(template)
|
||||||
|
template_with_content = create_content_for_notification(template, form.get('personalisation', {}))
|
||||||
|
check_sms_content_char_count(template_with_content.replaced_content_count)
|
||||||
|
return template, template_with_content.content
|
||||||
@@ -150,7 +150,7 @@ def test_should_not_send_notification_for_archived_template(notify_api, sample_t
|
|||||||
headers=[('Content-Type', 'application/json'), auth_header])
|
headers=[('Content-Type', 'application/json'), auth_header])
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
json_resp = json.loads(resp.get_data(as_text=True))
|
json_resp = json.loads(resp.get_data(as_text=True))
|
||||||
assert 'Template has been deleted' in json_resp['message']['template']
|
assert 'Template has been deleted' in json_resp['message']
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('template_type, to',
|
@pytest.mark.parametrize('template_type, to',
|
||||||
@@ -516,7 +516,7 @@ def test_should_not_send_sms_if_team_api_key_and_not_a_service_user(notify_api,
|
|||||||
def test_should_send_email_if_team_api_key_and_a_service_user(notify_api, sample_email_template, fake_uuid, mocker):
|
def test_should_send_email_if_team_api_key_and_a_service_user(notify_api, sample_email_template, fake_uuid, mocker):
|
||||||
with notify_api.test_request_context(), notify_api.test_client() as client:
|
with notify_api.test_request_context(), notify_api.test_client() as client:
|
||||||
mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
|
mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
|
||||||
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
|
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'to': sample_email_template.service.created_by.email_address,
|
'to': sample_email_template.service.created_by.email_address,
|
||||||
@@ -545,7 +545,7 @@ def test_should_send_sms_to_anyone_with_test_key(
|
|||||||
):
|
):
|
||||||
with notify_api.test_request_context(), notify_api.test_client() as client:
|
with notify_api.test_request_context(), notify_api.test_client() as client:
|
||||||
mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
|
mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
|
||||||
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
|
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'to': '07811111111',
|
'to': '07811111111',
|
||||||
@@ -578,7 +578,7 @@ def test_should_send_email_to_anyone_with_test_key(
|
|||||||
):
|
):
|
||||||
with notify_api.test_request_context(), notify_api.test_client() as client:
|
with notify_api.test_request_context(), notify_api.test_client() as client:
|
||||||
mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
|
mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
|
||||||
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
|
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'to': 'anyone123@example.com',
|
'to': 'anyone123@example.com',
|
||||||
@@ -608,7 +608,7 @@ def test_should_send_email_to_anyone_with_test_key(
|
|||||||
def test_should_send_sms_if_team_api_key_and_a_service_user(notify_api, sample_template, fake_uuid, mocker):
|
def test_should_send_sms_if_team_api_key_and_a_service_user(notify_api, sample_template, fake_uuid, mocker):
|
||||||
with notify_api.test_request_context(), notify_api.test_client() as client:
|
with notify_api.test_request_context(), notify_api.test_client() as client:
|
||||||
mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
|
mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
|
||||||
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
|
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'to': sample_template.service.created_by.mobile_number,
|
'to': sample_template.service.created_by.mobile_number,
|
||||||
@@ -638,7 +638,7 @@ def test_should_persist_notification(notify_api, sample_template,
|
|||||||
fake_uuid, mocker):
|
fake_uuid, mocker):
|
||||||
with notify_api.test_request_context(), notify_api.test_client() as client:
|
with notify_api.test_request_context(), notify_api.test_client() as client:
|
||||||
mocked = mocker.patch('app.celery.provider_tasks.deliver_{}.apply_async'.format(template_type))
|
mocked = mocker.patch('app.celery.provider_tasks.deliver_{}.apply_async'.format(template_type))
|
||||||
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
|
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||||
template = sample_template if template_type == 'sms' else sample_email_template
|
template = sample_template if template_type == 'sms' else sample_email_template
|
||||||
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
|
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
|
||||||
else sample_email_template.service.created_by.email_address
|
else sample_email_template.service.created_by.email_address
|
||||||
@@ -682,7 +682,7 @@ def test_should_delete_notification_and_return_error_if_sqs_fails(
|
|||||||
'app.celery.provider_tasks.deliver_{}.apply_async'.format(template_type),
|
'app.celery.provider_tasks.deliver_{}.apply_async'.format(template_type),
|
||||||
side_effect=Exception("failed to talk to SQS")
|
side_effect=Exception("failed to talk to SQS")
|
||||||
)
|
)
|
||||||
mocker.patch('app.notifications.rest.create_uuid', return_value=fake_uuid)
|
m1 = mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||||
template = sample_template if template_type == 'sms' else sample_email_template
|
template = sample_template if template_type == 'sms' else sample_email_template
|
||||||
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
|
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
|
||||||
else sample_email_template.service.created_by.email_address
|
else sample_email_template.service.created_by.email_address
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import pytest
|
||||||
|
from boto3.exceptions import Boto3Error
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.models import Template, Notification, NotificationHistory
|
||||||
|
from app.notifications.process_notifications import (create_content_for_notification,
|
||||||
|
persist_notification, send_notification_to_queue)
|
||||||
|
from app.v2.errors import BadRequestError
|
||||||
|
from tests.app.conftest import sample_notification, sample_template, sample_email_template
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_content_for_notification_passes(sample_email_template):
|
||||||
|
template = Template.query.get(sample_email_template.id)
|
||||||
|
content = create_content_for_notification(template, None)
|
||||||
|
assert content.replaced == template.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_content_for_notification_with_placeholders_passes(sample_template_with_placeholders):
|
||||||
|
template = Template.query.get(sample_template_with_placeholders.id)
|
||||||
|
content = create_content_for_notification(template, {'name': 'Bobby'})
|
||||||
|
assert content.content == template.content
|
||||||
|
assert 'Bobby' in content.replaced
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_content_for_notification_fails_with_missing_personalisation(sample_template_with_placeholders):
|
||||||
|
template = Template.query.get(sample_template_with_placeholders.id)
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
create_content_for_notification(template, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_content_for_notification_fails_with_additional_personalisation(sample_template_with_placeholders):
|
||||||
|
template = Template.query.get(sample_template_with_placeholders.id)
|
||||||
|
with pytest.raises(BadRequestError) as e:
|
||||||
|
create_content_for_notification(template, {'name': 'Bobby', 'Additional placeholder': 'Data'})
|
||||||
|
assert e.value.message == 'Template personalisation not needed for template: Additional placeholder'
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_notification_creates_and_save_to_db(sample_template, sample_api_key):
|
||||||
|
assert Notification.query.count() == 0
|
||||||
|
assert NotificationHistory.query.count() == 0
|
||||||
|
notification = persist_notification(sample_template.id, sample_template.version, '+447111111111',
|
||||||
|
sample_template.service.id, {}, 'sms', sample_api_key.id,
|
||||||
|
sample_api_key.key_type)
|
||||||
|
assert Notification.query.count() == 1
|
||||||
|
assert Notification.query.get(notification.id) is not None
|
||||||
|
assert NotificationHistory.query.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_notification_throws_exception_when_missing_template(sample_template, sample_api_key):
|
||||||
|
assert Notification.query.count() == 0
|
||||||
|
assert NotificationHistory.query.count() == 0
|
||||||
|
with pytest.raises(SQLAlchemyError):
|
||||||
|
persist_notification(template_id=None,
|
||||||
|
template_version=None,
|
||||||
|
recipient='+447111111111',
|
||||||
|
service_id=sample_template.service.id,
|
||||||
|
personalisation=None, notification_type='sms',
|
||||||
|
api_key_id=sample_api_key.id,
|
||||||
|
key_type=sample_api_key.key_type)
|
||||||
|
assert Notification.query.count() == 0
|
||||||
|
assert NotificationHistory.query.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('research_mode, queue, notification_type, key_type',
|
||||||
|
[(True, 'research-mode', 'sms', 'normal'),
|
||||||
|
(True, 'research-mode', 'email', 'normal'),
|
||||||
|
(True, 'research-mode', 'email', 'team'),
|
||||||
|
(False, 'send-sms', 'sms', 'normal'),
|
||||||
|
(False, 'send-email', 'email', 'normal'),
|
||||||
|
(False, 'send-sms', 'sms', 'team'),
|
||||||
|
(False, 'research-mode', 'sms', 'test')])
|
||||||
|
def test_send_notification_to_queue(notify_db, notify_db_session,
|
||||||
|
research_mode, notification_type,
|
||||||
|
queue, key_type, mocker):
|
||||||
|
mocked = mocker.patch('app.celery.provider_tasks.deliver_{}.apply_async'.format(notification_type))
|
||||||
|
template = sample_template(notify_db, notify_db_session) if notification_type == 'sms' \
|
||||||
|
else sample_email_template(notify_db, notify_db_session)
|
||||||
|
notification = sample_notification(notify_db, notify_db_session, template=template, key_type=key_type)
|
||||||
|
send_notification_to_queue(notification=notification, research_mode=research_mode)
|
||||||
|
|
||||||
|
mocked.assert_called_once_with([str(notification.id)], queue=queue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_notification_to_queue_throws_exception_deletes_notification(sample_notification, mocker):
|
||||||
|
mocked = mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async', side_effect=Boto3Error("EXPECTED"))
|
||||||
|
with pytest.raises(Boto3Error):
|
||||||
|
send_notification_to_queue(sample_notification, False)
|
||||||
|
mocked.assert_called_once_with([(str(sample_notification.id))], queue='send-sms')
|
||||||
|
|
||||||
|
assert Notification.query.count() == 0
|
||||||
|
assert NotificationHistory.query.count() == 0
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import pytest
|
||||||
|
from app.notifications.validators import check_service_message_limit, check_template_is_for_notification_type, \
|
||||||
|
check_template_is_active, service_can_send_to_recipient, check_sms_content_char_count
|
||||||
|
from app.v2.errors import BadRequestError, TooManyRequestsError
|
||||||
|
from tests.app.conftest import (sample_notification as create_notification,
|
||||||
|
sample_service as create_service, sample_service_whitelist)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('key_type', ['test', 'team', 'normal'])
|
||||||
|
def test_check_service_message_limit_with_unrestricted_service_passes(key_type,
|
||||||
|
sample_service,
|
||||||
|
sample_notification):
|
||||||
|
assert check_service_message_limit(key_type, sample_service) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('key_type', ['test', 'team', 'normal'])
|
||||||
|
def test_check_service_message_limit_under_message_limit_passes(key_type,
|
||||||
|
sample_service,
|
||||||
|
sample_notification):
|
||||||
|
assert check_service_message_limit(key_type, sample_service) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('key_type', ['team', 'normal'])
|
||||||
|
def test_check_service_message_limit_over_message_limit_fails(key_type, notify_db, notify_db_session):
|
||||||
|
service = create_service(notify_db, notify_db_session, restricted=True, limit=4)
|
||||||
|
for x in range(5):
|
||||||
|
create_notification(notify_db, notify_db_session, service=service)
|
||||||
|
with pytest.raises(TooManyRequestsError) as e:
|
||||||
|
check_service_message_limit(key_type, service)
|
||||||
|
assert e.value.status_code == 429
|
||||||
|
assert e.value.code == '10429'
|
||||||
|
assert e.value.message == 'Exceeded send limits (4) for today'
|
||||||
|
assert e.value.fields == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('template_type, notification_type',
|
||||||
|
[('email', 'email'),
|
||||||
|
('sms', 'sms')])
|
||||||
|
def test_check_template_is_for_notification_type_pass(template_type, notification_type):
|
||||||
|
assert check_template_is_for_notification_type(notification_type=notification_type,
|
||||||
|
template_type=template_type) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('template_type, notification_type',
|
||||||
|
[('sms', 'email'),
|
||||||
|
('email', 'sms')])
|
||||||
|
def test_check_template_is_for_notification_type_fails_when_template_type_does_not_match_notification_type(
|
||||||
|
template_type, notification_type):
|
||||||
|
with pytest.raises(BadRequestError) as e:
|
||||||
|
check_template_is_for_notification_type(notification_type=notification_type,
|
||||||
|
template_type=template_type)
|
||||||
|
assert e.value.code == 10400
|
||||||
|
error_message = '{0} template is not suitable for {1} notification'.format(template_type, notification_type)
|
||||||
|
assert e.value.message == error_message
|
||||||
|
assert e.value.link == 'link to documentation'
|
||||||
|
assert e.value.fields == [{'template': error_message}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_template_is_active_passes(sample_template):
|
||||||
|
assert check_template_is_active(sample_template) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_template_is_active_fails(sample_template):
|
||||||
|
sample_template.archived = True
|
||||||
|
from app.dao.templates_dao import dao_update_template
|
||||||
|
dao_update_template(sample_template)
|
||||||
|
with pytest.raises(BadRequestError) as e:
|
||||||
|
check_template_is_active(sample_template)
|
||||||
|
assert e.value.status_code == 400
|
||||||
|
assert e.value.code == 10400
|
||||||
|
assert e.value.message == 'Template has been deleted'
|
||||||
|
assert e.value.link == "link to documentation"
|
||||||
|
assert e.value.fields == [{'template': 'Template has been deleted'}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('key_type',
|
||||||
|
['test', 'normal'])
|
||||||
|
def test_service_can_send_to_recipient_passes(key_type, notify_db, notify_db_session):
|
||||||
|
trial_mode_service = create_service(notify_db, notify_db_session, service_name='trial mode', restricted=True)
|
||||||
|
assert service_can_send_to_recipient(trial_mode_service.users[0].email_address,
|
||||||
|
key_type,
|
||||||
|
trial_mode_service) is None
|
||||||
|
assert service_can_send_to_recipient(trial_mode_service.users[0].mobile_number,
|
||||||
|
key_type,
|
||||||
|
trial_mode_service) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('key_type',
|
||||||
|
['test', 'normal'])
|
||||||
|
def test_service_can_send_to_recipient_passes_for_live_service_non_team_member(key_type, notify_db, notify_db_session):
|
||||||
|
live_service = create_service(notify_db, notify_db_session, service_name='live', restricted=False)
|
||||||
|
assert service_can_send_to_recipient("some_other_email@test.com",
|
||||||
|
key_type,
|
||||||
|
live_service) is None
|
||||||
|
assert service_can_send_to_recipient('07513332413',
|
||||||
|
key_type,
|
||||||
|
live_service) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_can_send_to_recipient_passes_for_whitelisted_recipient_passes(notify_db, notify_db_session,
|
||||||
|
sample_service):
|
||||||
|
sample_service_whitelist(notify_db, notify_db_session, email_address="some_other_email@test.com")
|
||||||
|
assert service_can_send_to_recipient("some_other_email@test.com",
|
||||||
|
'team',
|
||||||
|
sample_service) is None
|
||||||
|
sample_service_whitelist(notify_db, notify_db_session, mobile_number='07513332413')
|
||||||
|
assert service_can_send_to_recipient('07513332413',
|
||||||
|
'team',
|
||||||
|
sample_service) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('recipient', ['07513332413', 'some_other_email@test.com'])
|
||||||
|
@pytest.mark.parametrize('key_type, error_message',
|
||||||
|
[('team', 'Can’t send to this recipient using a team-only API key'),
|
||||||
|
('normal',
|
||||||
|
"Can’t send to this recipient when service is in trial mode – see https://www.notifications.service.gov.uk/trial-mode")]) # noqa
|
||||||
|
def test_service_can_send_to_recipient_fails_when_recipient_is_not_on_team(recipient, key_type, error_message,
|
||||||
|
notify_db, notify_db_session):
|
||||||
|
trial_mode_service = create_service(notify_db, notify_db_session, service_name='trial mode', restricted=True)
|
||||||
|
with pytest.raises(BadRequestError) as exec_info:
|
||||||
|
service_can_send_to_recipient(recipient,
|
||||||
|
key_type,
|
||||||
|
trial_mode_service)
|
||||||
|
assert exec_info.value.status_code == 400
|
||||||
|
assert exec_info.value.code == 10400
|
||||||
|
assert exec_info.value.message == error_message
|
||||||
|
assert exec_info.value.link == 'link to documentation'
|
||||||
|
assert exec_info.value.fields == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_can_send_to_recipient_fails_when_mobile_number_is_not_on_team(notify_db, notify_db_session):
|
||||||
|
live_service = create_service(notify_db, notify_db_session, service_name='live mode', restricted=False)
|
||||||
|
with pytest.raises(BadRequestError) as e:
|
||||||
|
service_can_send_to_recipient("0758964221",
|
||||||
|
'team',
|
||||||
|
live_service)
|
||||||
|
assert e.value.status_code == 400
|
||||||
|
assert e.value.code == 10400
|
||||||
|
assert e.value.message == 'Can’t send to this recipient using a team-only API key'
|
||||||
|
assert e.value.link == 'link to documentation'
|
||||||
|
assert e.value.fields == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('char_count', [495, 0, 494, 200])
|
||||||
|
def test_check_sms_content_char_count_passes(char_count, notify_api):
|
||||||
|
assert check_sms_content_char_count(char_count) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('char_count', [496, 500, 6000])
|
||||||
|
def test_check_sms_content_char_count_fails(char_count, notify_api):
|
||||||
|
with pytest.raises(BadRequestError) as e:
|
||||||
|
check_sms_content_char_count(char_count)
|
||||||
|
assert e.value.status_code == 400
|
||||||
|
assert e.value.code == 10400
|
||||||
|
assert e.value.message == 'Content for template has a character count greater than the limit of {}'.format(
|
||||||
|
notify_api.config['SMS_CHAR_COUNT_LIMIT'])
|
||||||
|
assert e.value.link == 'link to documentation'
|
||||||
|
assert e.value.fields == []
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import json
|
||||||
|
from jsonschema import ValidationError
|
||||||
|
|
||||||
|
from app.v2.notifications.notification_schemas import post_sms_request, post_sms_response
|
||||||
|
from app.schema_validation import validate
|
||||||
|
|
||||||
|
valid_json = {"phone_number": "07515111111",
|
||||||
|
"template_id": str(uuid.uuid4())
|
||||||
|
}
|
||||||
|
valid_json_with_optionals = {
|
||||||
|
"phone_number": "07515111111",
|
||||||
|
"template_id": str(uuid.uuid4()),
|
||||||
|
"reference": "reference from caller",
|
||||||
|
"personalisation": {"key": "value"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("input", [valid_json, valid_json_with_optionals])
|
||||||
|
def test_post_sms_schema_is_valid(input):
|
||||||
|
validate(input, post_sms_request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_sms_json_schema_bad_uuid_and_missing_phone_number():
|
||||||
|
j = {"template_id": "notUUID"}
|
||||||
|
with pytest.raises(ValidationError) as e:
|
||||||
|
validate(j, post_sms_request)
|
||||||
|
error = json.loads(e.value.message)
|
||||||
|
assert "POST v2/notifications/sms" in error['message']
|
||||||
|
assert len(error.get('fields')) == 2
|
||||||
|
assert {"phone_number": "is a required property"} in error['fields']
|
||||||
|
assert {"template_id": "not a valid UUID"} in error['fields']
|
||||||
|
assert error.get('code') == '1001'
|
||||||
|
assert error.get('link', None) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_sms_schema_with_personalisation_that_is_not_a_dict():
|
||||||
|
j = {
|
||||||
|
"phone_number": "07515111111",
|
||||||
|
"template_id": str(uuid.uuid4()),
|
||||||
|
"reference": "reference from caller",
|
||||||
|
"personalisation": "not_a_dict"
|
||||||
|
}
|
||||||
|
with pytest.raises(ValidationError) as e:
|
||||||
|
validate(j, post_sms_request)
|
||||||
|
error = json.loads(e.value.message)
|
||||||
|
assert "POST v2/notifications/sms" in error['message']
|
||||||
|
assert len(error.get('fields')) == 1
|
||||||
|
assert error['fields'][0] == {"personalisation": "should contain key value pairs"}
|
||||||
|
assert error.get('code') == '1001'
|
||||||
|
assert error.get('link', None) == 'link to error documentation (not yet implemented)'
|
||||||
|
|
||||||
|
|
||||||
|
valid_response = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"content": {"body": "contents of message",
|
||||||
|
"from_number": "46045"},
|
||||||
|
"uri": "/v2/notifications/id",
|
||||||
|
"template": {"id": str(uuid.uuid4()),
|
||||||
|
"version": 1,
|
||||||
|
"uri": "/v2/template/id"}
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_response_with_optionals = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"reference": "reference_from_service",
|
||||||
|
"content": {"body": "contents of message",
|
||||||
|
"from_number": "46045"},
|
||||||
|
"uri": "/v2/notifications/id",
|
||||||
|
"template": {"id": str(uuid.uuid4()),
|
||||||
|
"version": 1,
|
||||||
|
"uri": "/v2/template/id"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input', [valid_response])
|
||||||
|
def test_post_sms_response_schema_is_valid(input):
|
||||||
|
validate(input, post_sms_response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_sms_response_schema_missing_uri():
|
||||||
|
j = valid_response
|
||||||
|
del j["uri"]
|
||||||
|
with pytest.raises(ValidationError) as e:
|
||||||
|
validate(j, post_sms_response)
|
||||||
|
error = json.loads(e.value.message)
|
||||||
|
assert '1001' == error['code']
|
||||||
|
assert 'link to error documentation (not yet implemented)' == error['link']
|
||||||
|
assert 'Validation error occurred - response v2/notifications/sms' == error['message']
|
||||||
|
assert [{"uri": "is a required property"}] == error['fields']
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask import json
|
||||||
|
|
||||||
|
from app.models import Notification
|
||||||
|
from tests import create_authorization_header
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_sms_notification_returns_201(notify_api, sample_template, mocker):
|
||||||
|
with notify_api.test_request_context():
|
||||||
|
with notify_api.test_client() as client:
|
||||||
|
mocked = mocker.patch('app.celery.provider_tasks.deliver_sms.apply_async')
|
||||||
|
data = {
|
||||||
|
'phone_number': '+447700900855',
|
||||||
|
'template_id': str(sample_template.id)
|
||||||
|
}
|
||||||
|
auth_header = create_authorization_header(service_id=sample_template.service_id)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
path='/v2/notifications/sms',
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=[('Content-Type', 'application/json'), auth_header])
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
resp_json = json.loads(response.get_data(as_text=True))
|
||||||
|
notifications = Notification.query.all()
|
||||||
|
assert len(notifications) == 1
|
||||||
|
notification_id = notifications[0].id
|
||||||
|
assert resp_json['id'] is not None
|
||||||
|
assert resp_json['reference'] is None
|
||||||
|
assert resp_json['content']['body'] == sample_template.content
|
||||||
|
assert resp_json['content']['from_number'] == sample_template.service.sms_sender
|
||||||
|
assert 'v2/notifications/{}'.format(notification_id) in resp_json['uri']
|
||||||
|
assert resp_json['template']['id'] == str(sample_template.id)
|
||||||
|
assert resp_json['template']['version'] == sample_template.version
|
||||||
|
assert 'v2/templates/{}'.format(sample_template.id) in resp_json['template']['uri']
|
||||||
|
assert mocked.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_sms_notification_returns_404_and_missing_template(notify_api, sample_service):
|
||||||
|
with notify_api.test_request_context():
|
||||||
|
with notify_api.test_client() as client:
|
||||||
|
data = {
|
||||||
|
'phone_number': '+447700900855',
|
||||||
|
'template_id': str(uuid.uuid4())
|
||||||
|
}
|
||||||
|
auth_header = create_authorization_header(service_id=sample_service.id)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
path='/v2/notifications/sms',
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=[('Content-Type', 'application/json'), auth_header])
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.headers['Content-type'] == 'application/json'
|
||||||
|
|
||||||
|
error_json = json.loads(response.get_data(as_text=True))
|
||||||
|
assert error_json['code'] == 10400
|
||||||
|
assert error_json['message'] == 'Template not found'
|
||||||
|
assert error_json['fields'] == [{'template': 'Template not found'}]
|
||||||
|
assert error_json['link'] == 'link to documentation'
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_sms_notification_returns_403_and_well_formed_auth_error(notify_api, sample_template):
|
||||||
|
with notify_api.test_request_context():
|
||||||
|
with notify_api.test_client() as client:
|
||||||
|
data = {
|
||||||
|
'phone_number': '+447700900855',
|
||||||
|
'template_id': str(sample_template.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
path='/v2/notifications/sms',
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=[('Content-Type', 'application/json')])
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.headers['Content-type'] == 'application/json'
|
||||||
|
error_resp = json.loads(response.get_data(as_text=True))
|
||||||
|
assert error_resp['code'] == 401
|
||||||
|
assert error_resp['message'] == 'Unauthorized, authentication token must be provided'
|
||||||
|
assert error_resp['fields'] == {'token': ['Unauthorized, authentication token must be provided']}
|
||||||
|
assert error_resp['link'] == 'link to docs'
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_sms_notification_returns_400_and_for_schema_problems(notify_api, sample_template):
|
||||||
|
with notify_api.test_request_context():
|
||||||
|
with notify_api.test_client() as client:
|
||||||
|
data = {
|
||||||
|
'phone_number': '+447700900855',
|
||||||
|
'template': str(sample_template.id)
|
||||||
|
}
|
||||||
|
auth_header = create_authorization_header(service_id=sample_template.service_id)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
path='/v2/notifications/sms',
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=[('Content-Type', 'application/json'), auth_header])
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.headers['Content-type'] == 'application/json'
|
||||||
|
error_resp = json.loads(response.get_data(as_text=True))
|
||||||
|
assert error_resp['code'] == '1001'
|
||||||
|
assert error_resp['message'] == 'Validation error occurred - POST v2/notifications/sms'
|
||||||
|
assert error_resp['link'] == "link to error documentation (not yet implemented)"
|
||||||
|
assert error_resp['fields'] == [{"template_id": "is a required property"}]
|
||||||
Reference in New Issue
Block a user