mirror of
https://github.com/GSA/notifications-api.git
synced 2026-08-02 12:48:32 -04:00
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
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.ext.sqlalchemy import SQLAlchemy
|
||||
from flask_marshmallow import Marshmallow
|
||||
@@ -57,6 +57,13 @@ def create_app(app_name=None):
|
||||
encryption.init_app(application)
|
||||
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.user.rest import user as user_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(delivery_blueprint)
|
||||
application.register_blueprint(accept_invite, url_prefix='/invite')
|
||||
|
||||
application.register_blueprint(template_statistics_blueprint)
|
||||
application.register_blueprint(events_blueprint)
|
||||
application.register_blueprint(provider_details_blueprint, url_prefix='/provider-details')
|
||||
application.register_blueprint(spec_blueprint, url_prefix='/spec')
|
||||
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):
|
||||
@@ -120,6 +129,12 @@ def init_app(app):
|
||||
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
|
||||
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():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@@ -12,8 +12,15 @@ from app.dao.services_dao import dao_fetch_service_by_id
|
||||
class AuthError(Exception):
|
||||
def __init__(self, message, code):
|
||||
self.message = {"token": [message]}
|
||||
self.short_message = message
|
||||
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):
|
||||
auth_header = req.headers.get('Authorization', None)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
import pytz
|
||||
from datetime import (
|
||||
datetime,
|
||||
@@ -12,7 +11,7 @@ from werkzeug.datastructures import MultiDict
|
||||
from sqlalchemy import (desc, func, or_, and_, asc, cast, Text)
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app import db
|
||||
from app import db, create_uuid
|
||||
from app.dao import days_ago
|
||||
from app.models import (
|
||||
Service,
|
||||
@@ -125,7 +124,7 @@ def dao_get_last_template_usage(template_id):
|
||||
def dao_create_notification(notification):
|
||||
if not notification.id:
|
||||
# need to populate defaulted fields before we create the notification history object
|
||||
notification.id = uuid.uuid4()
|
||||
notification.id = create_uuid()
|
||||
if not notification.status:
|
||||
notification.status = 'created'
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ from app.authentication.auth import AuthError
|
||||
|
||||
|
||||
class InvalidRequest(Exception):
|
||||
code = None
|
||||
link = None
|
||||
fields = []
|
||||
|
||||
def __init__(self, message, status_code):
|
||||
super().__init__()
|
||||
@@ -18,62 +21,67 @@ class InvalidRequest(Exception):
|
||||
def to_dict(self):
|
||||
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):
|
||||
return str(self.to_dict())
|
||||
|
||||
|
||||
def register_errors(blueprint):
|
||||
|
||||
@blueprint.app_errorhandler(AuthError)
|
||||
@blueprint.errorhandler(AuthError)
|
||||
def authentication_error(error):
|
||||
return jsonify(result='error', message=error.message), error.code
|
||||
|
||||
@blueprint.app_errorhandler(ValidationError)
|
||||
@blueprint.errorhandler(ValidationError)
|
||||
def validation_error(error):
|
||||
current_app.logger.error(error)
|
||||
return jsonify(result='error', message=error.messages), 400
|
||||
|
||||
@blueprint.app_errorhandler(InvalidRequest)
|
||||
@blueprint.errorhandler(InvalidRequest)
|
||||
def invalid_data(error):
|
||||
response = jsonify(error.to_dict())
|
||||
response.status_code = error.status_code
|
||||
current_app.logger.error(error)
|
||||
return response
|
||||
|
||||
@blueprint.app_errorhandler(400)
|
||||
@blueprint.errorhandler(400)
|
||||
def bad_request(e):
|
||||
msg = e.description or "Invalid request parameters"
|
||||
current_app.logger.exception(msg)
|
||||
return jsonify(result='error', message=str(msg)), 400
|
||||
|
||||
@blueprint.app_errorhandler(401)
|
||||
@blueprint.errorhandler(401)
|
||||
def unauthorized(e):
|
||||
error_message = "Unauthorized, authentication token must be provided"
|
||||
return jsonify(result='error', message=error_message), 401, [('WWW-Authenticate', 'Bearer')]
|
||||
|
||||
@blueprint.app_errorhandler(403)
|
||||
@blueprint.errorhandler(403)
|
||||
def forbidden(e):
|
||||
error_message = "Forbidden, invalid authentication token provided"
|
||||
return jsonify(result='error', message=error_message), 403
|
||||
|
||||
@blueprint.app_errorhandler(404)
|
||||
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)
|
||||
@blueprint.errorhandler(429)
|
||||
def limit_exceeded(e):
|
||||
current_app.logger.exception(e)
|
||||
return jsonify(result='error', message=str(e.description)), 429
|
||||
|
||||
@blueprint.app_errorhandler(NoResultFound)
|
||||
@blueprint.app_errorhandler(DataError)
|
||||
@blueprint.errorhandler(NoResultFound)
|
||||
@blueprint.errorhandler(DataError)
|
||||
def no_result_found(e):
|
||||
current_app.logger.exception(e)
|
||||
return jsonify(result='error', message="No result found"), 404
|
||||
|
||||
@blueprint.app_errorhandler(SQLAlchemyError)
|
||||
@blueprint.errorhandler(SQLAlchemyError)
|
||||
def db_error(e):
|
||||
current_app.logger.exception(e)
|
||||
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
|
||||
@blueprint.app_errorhandler(500)
|
||||
@blueprint.app_errorhandler(Exception)
|
||||
@blueprint.errorhandler(Exception)
|
||||
def internal_server_error(e):
|
||||
current_app.logger.exception(e)
|
||||
return jsonify(result='error', message="Internal server error"), 500
|
||||
|
||||
@@ -563,6 +563,29 @@ class Notification(db.Model):
|
||||
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):
|
||||
__tablename__ = 'notification_history'
|
||||
|
||||
76
app/notifications/process_notifications.py
Normal file
76
app/notifications/process_notifications.py
Normal file
@@ -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)
|
||||
)
|
||||
@@ -7,33 +7,34 @@ from flask import (
|
||||
current_app,
|
||||
json
|
||||
)
|
||||
|
||||
from notifications_utils.template import Template
|
||||
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 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 (
|
||||
templates_dao,
|
||||
services_dao,
|
||||
notifications_dao
|
||||
)
|
||||
from app.models import KEY_TYPE_TEAM
|
||||
from app.models import SMS_TYPE
|
||||
from app.notifications.process_client_response import (
|
||||
validate_callback_data,
|
||||
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 (
|
||||
email_notification_schema,
|
||||
sms_template_notification_schema,
|
||||
notification_with_personalisation_schema,
|
||||
notifications_filter_schema,
|
||||
notifications_statistics_schema,
|
||||
day_schema,
|
||||
unarchived_template_schema
|
||||
day_schema
|
||||
)
|
||||
from app.service.utils import service_allowed_to_send_to
|
||||
from app.utils import pagination_links
|
||||
|
||||
notifications = Blueprint('notifications', __name__)
|
||||
@@ -44,7 +45,6 @@ from app.errors import (
|
||||
)
|
||||
|
||||
register_errors(notifications)
|
||||
from app.celery import provider_tasks
|
||||
|
||||
|
||||
@notifications.route('/notifications/email/ses', methods=['POST'])
|
||||
@@ -216,43 +216,32 @@ def send_notification(notification_type):
|
||||
if errors:
|
||||
raise InvalidRequest(errors, status_code=400)
|
||||
|
||||
if all((api_user.key_type != KEY_TYPE_TEST,
|
||||
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)
|
||||
check_service_message_limit(api_user.key_type, service)
|
||||
|
||||
template = templates_dao.dao_get_template_by_id_and_service_id(
|
||||
template_id=notification['template'],
|
||||
service_id=service_id
|
||||
)
|
||||
template = templates_dao.dao_get_template_by_id_and_service_id(template_id=notification['template'],
|
||||
service_id=service_id)
|
||||
|
||||
if notification_type != template.template_type:
|
||||
raise InvalidRequest("{0} template is not suitable for {1} notification".format(template.template_type,
|
||||
notification_type),
|
||||
status_code=400)
|
||||
|
||||
errors = unarchived_template_schema.validate({'archived': template.archived})
|
||||
if errors:
|
||||
raise InvalidRequest(errors, status_code=400)
|
||||
check_template_is_for_notification_type(notification_type, template.template_type)
|
||||
check_template_is_active(template)
|
||||
|
||||
template_object = create_template_object_for_notification(template, notification.get('personalisation', {}))
|
||||
|
||||
_service_allowed_to_send_to(notification, service)
|
||||
|
||||
notification_id = create_uuid()
|
||||
notification.update({"template_version": template.version})
|
||||
saved_notification = None
|
||||
if not _simulated_recipient(notification['to'], notification_type):
|
||||
persist_notification(
|
||||
service,
|
||||
notification_id,
|
||||
notification,
|
||||
datetime.utcnow().strftime(DATETIME_FORMAT),
|
||||
notification_type,
|
||||
str(api_user.id),
|
||||
api_user.key_type
|
||||
)
|
||||
saved_notification = persist_notification(template_id=template.id,
|
||||
template_version=template.version,
|
||||
recipient=notification['to'],
|
||||
service_id=service.id,
|
||||
personalisation=notification.get('personalisation', None),
|
||||
notification_type=notification_type,
|
||||
api_key_id=api_user.id,
|
||||
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(
|
||||
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'])
|
||||
|
||||
|
||||
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):
|
||||
if not service_allowed_to_send_to(notification['to'], service, api_user.key_type):
|
||||
if api_user.key_type == KEY_TYPE_TEAM:
|
||||
|
||||
48
app/notifications/validators.py
Normal file
48
app/notifications/validators.py
Normal file
@@ -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)
|
||||
28
app/schema_validation/__init__.py
Normal file
28
app/schema_validation/__init__.py
Normal file
@@ -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)
|
||||
20
app/schema_validation/definitions.py
Normal file
20
app/schema_validation/definitions.py
Normal file
@@ -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
app/v2/__init__.py
Normal file
0
app/v2/__init__.py
Normal file
59
app/v2/errors.py
Normal file
59
app/v2/errors.py
Normal file
@@ -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
|
||||
7
app/v2/notifications/__init__.py
Normal file
7
app/v2/notifications/__init__.py
Normal file
@@ -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)
|
||||
14
app/v2/notifications/get_notifications.py
Normal file
14
app/v2/notifications/get_notifications.py
Normal file
@@ -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
|
||||
68
app/v2/notifications/notification_schemas.py
Normal file
68
app/v2/notifications/notification_schemas.py
Normal file
@@ -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))}
|
||||
}
|
||||
71
app/v2/notifications/post_notifications.py
Normal file
71
app/v2/notifications/post_notifications.py
Normal file
@@ -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])
|
||||
assert resp.status_code == 400
|
||||
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',
|
||||
@@ -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):
|
||||
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.notifications.rest.create_uuid', return_value=fake_uuid)
|
||||
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||
|
||||
data = {
|
||||
'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:
|
||||
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 = {
|
||||
'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:
|
||||
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 = {
|
||||
'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):
|
||||
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.notifications.rest.create_uuid', return_value=fake_uuid)
|
||||
mocker.patch('app.dao.notifications_dao.create_uuid', return_value=fake_uuid)
|
||||
|
||||
data = {
|
||||
'to': sample_template.service.created_by.mobile_number,
|
||||
@@ -638,7 +638,7 @@ def test_should_persist_notification(notify_api, sample_template,
|
||||
fake_uuid, mocker):
|
||||
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))
|
||||
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
|
||||
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
|
||||
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),
|
||||
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
|
||||
to = sample_template.service.created_by.mobile_number if template_type == 'sms' \
|
||||
else sample_email_template.service.created_by.email_address
|
||||
|
||||
91
tests/app/notifications/test_process_notification.py
Normal file
91
tests/app/notifications/test_process_notification.py
Normal file
@@ -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
|
||||
158
tests/app/notifications/test_validators.py
Normal file
158
tests/app/notifications/test_validators.py
Normal file
@@ -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
tests/app/v2/__init__.py
Normal file
0
tests/app/v2/__init__.py
Normal file
0
tests/app/v2/notifications/__init__.py
Normal file
0
tests/app/v2/notifications/__init__.py
Normal file
92
tests/app/v2/notifications/test_notification_schemas.py
Normal file
92
tests/app/v2/notifications/test_notification_schemas.py
Normal file
@@ -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']
|
||||
106
tests/app/v2/notifications/test_post_notifications.py
Normal file
106
tests/app/v2/notifications/test_post_notifications.py
Normal file
@@ -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