Files
notifications-api/app/v2/notifications/post_notifications.py

338 lines
12 KiB
Python
Raw Normal View History

import functools
import uuid
2021-10-29 10:51:22 +01:00
import botocore
2021-03-10 13:55:06 +00:00
from flask import abort, current_app, jsonify, request
from app import api_user, authenticated_service, document_download_client, encryption
from app.celery.tasks import save_api_email, save_api_sms
from app.clients.document_download import DocumentDownloadError
from app.config import QueueNames
from app.enums import KeyType, NotificationStatus, NotificationType, TemplateProcessType
from app.models import Notification
from app.notifications.process_notifications import (
persist_notification,
2021-03-10 13:55:06 +00:00
send_notification_to_queue_detached,
simulated_recipient,
2021-03-10 13:55:06 +00:00
)
from app.notifications.validators import (
check_if_service_can_send_files_by_email,
2021-03-10 13:55:06 +00:00
check_is_message_too_long,
check_rate_limiting,
check_service_email_reply_to_id,
check_service_has_permission,
check_service_sms_sender_id,
validate_and_format_recipient,
[2/10] Allow API calls to specify the reply address option (#1291) * Added service_email_reply_to_id to the POST /v2/notifications/email and a test to test the validator * Caught NoResultFound exception in check_service_email_reply_to_id as it was not being caught when there there was no valid service_id or reply_to_id. Fixed failing tests which were not passing due to the NoResultFound exception and added further tests to check for the good path through the code and an test to check for an invalid service_id * Added service_email_reply_to_id to the POST /v2/notifications/email and a test to test the validator * Caught NoResultFound exception in check_service_email_reply_to_id as it was not being caught when there there was no valid service_id or reply_to_id. Fixed failing tests which were not passing due to the NoResultFound exception and added further tests to check for the good path through the code and an test to check for an invalid service_id * Fixed code style in validators.py to confirm with rules Update the name of email_reply_to_id to conform better with other attributes in the schema and the resultant code in post_notifications.py Fixed code style in test_validators.py to confirm with rules Added tests to test_post_notifications.py to test the email_reply_to_id being present and being incorrect, it being optional is being tested by other tests. * Added service_email_reply_to_id to the POST /v2/notifications/email and a test to test the validator * Added service_email_reply_to_id to the POST /v2/notifications/email and a test to test the validator * Caught NoResultFound exception in check_service_email_reply_to_id as it was not being caught when there there was no valid service_id or reply_to_id. Fixed failing tests which were not passing due to the NoResultFound exception and added further tests to check for the good path through the code and an test to check for an invalid service_id * Caught NoResultFound exception in check_service_email_reply_to_id as it was not being caught when there there was no valid service_id or reply_to_id. Fixed failing tests which were not passing due to the NoResultFound exception and added further tests to check for the good path through the code and an test to check for an invalid service_id * Fixed code style in validators.py to confirm with rules Update the name of email_reply_to_id to conform better with other attributes in the schema and the resultant code in post_notifications.py Fixed code style in test_validators.py to confirm with rules Added tests to test_post_notifications.py to test the email_reply_to_id being present and being incorrect, it being optional is being tested by other tests. * Minor update after manual merge to fix check style rule break in test_validators.py where a single space was introduced. * Updates after code review. Moved the template from the exception message as it was not required and updated the error message to match the field name in the sschema for better debugging and error identification. * Fixed test after update of exception message
2017-10-04 14:34:45 +01:00
validate_template,
2021-03-10 13:55:06 +00:00
)
from app.schema_validation import validate
2024-05-23 13:59:51 -07:00
from app.utils import DATETIME_FORMAT, utc_now
from app.v2.errors import BadRequestError
2021-03-10 13:55:06 +00:00
from app.v2.notifications import v2_notification_blueprint
from app.v2.notifications.create_response import (
create_post_email_response_from_notification,
create_post_sms_response_from_notification,
)
from app.v2.notifications.notification_schemas import (
post_email_request,
2021-03-10 13:55:06 +00:00
post_sms_request,
)
from app.v2.utils import get_valid_json
from notifications_utils.recipients import try_validate_and_format_phone_number
2023-08-29 14:54:30 -07:00
@v2_notification_blueprint.route("/<notification_type>", methods=["POST"])
def post_notification(notification_type):
2023-08-17 09:01:53 -07:00
request_json = get_valid_json()
if notification_type == NotificationType.EMAIL:
2023-08-17 09:01:53 -07:00
form = validate(request_json, post_email_request)
elif notification_type == NotificationType.SMS:
2023-08-17 09:01:53 -07:00
form = validate(request_json, post_sms_request)
else:
abort(404)
check_service_has_permission(notification_type, authenticated_service.permissions)
check_rate_limiting(authenticated_service, api_user)
template, template_with_content = validate_template(
2023-08-29 14:54:30 -07:00
form["template_id"],
form.get("personalisation", {}),
authenticated_service,
notification_type,
2023-08-29 14:54:30 -07:00
check_char_count=False,
)
reply_to = get_reply_to_text(notification_type, form, template)
notification = process_sms_or_email_notification(
form=form,
notification_type=notification_type,
template=template,
template_with_content=template_with_content,
template_process_type=template.process_type,
service=authenticated_service,
2023-08-29 14:54:30 -07:00
reply_to_text=reply_to,
)
return jsonify(notification), 201
def process_sms_or_email_notification(
*,
form,
notification_type,
template,
template_with_content,
template_process_type,
service,
reply_to_text=None,
):
notification_id = uuid.uuid4()
2023-08-29 14:54:30 -07:00
form_send_to = (
form["email_address"]
if notification_type == NotificationType.EMAIL
2023-08-29 14:54:30 -07:00
else form["phone_number"]
)
2023-08-29 14:54:30 -07:00
send_to = validate_and_format_recipient(
send_to=form_send_to,
key_type=api_user.key_type,
service=service,
notification_type=notification_type,
)
# Do not persist or send notification to the queue if it is a simulated recipient
simulated = simulated_recipient(send_to, notification_type)
personalisation, document_download_count = process_document_uploads(
2023-08-29 14:54:30 -07:00
form.get("personalisation"), service, simulated=simulated
)
if document_download_count:
# We changed personalisation which means we need to update the content
template_with_content.values = personalisation
2020-06-26 14:23:25 +01:00
# validate content length after url is replaced in personalisation.
check_is_message_too_long(template_with_content)
resp = create_response_for_post_notification(
notification_id=notification_id,
2023-08-29 14:54:30 -07:00
client_reference=form.get("reference", None),
template_id=template.id,
template_version=template.version,
service_id=service.id,
notification_type=notification_type,
reply_to=reply_to_text,
2023-08-29 14:54:30 -07:00
template_with_content=template_with_content,
)
2023-08-29 14:54:30 -07:00
if (
service.high_volume
and api_user.key_type == KeyType.NORMAL
and notification_type in {NotificationType.EMAIL, NotificationType.SMS}
2023-08-29 14:54:30 -07:00
):
# Put service with high volumes of notifications onto a queue
# To take the pressure off the db for API requests put the notification for our high volume service onto a queue
# the task will then save the notification, then call send_notification_to_queue.
# NOTE: The high volume service should be aware that the notification is not immediately
# available by a GET request, it is recommend they use callbacks to keep track of status updates.
try:
save_email_or_sms_to_queue(
form=form,
notification_id=str(notification_id),
notification_type=notification_type,
2020-06-26 14:23:25 +01:00
api_key=api_user,
template=template,
service_id=service.id,
personalisation=personalisation,
document_download_count=document_download_count,
2023-08-29 14:54:30 -07:00
reply_to_text=reply_to_text,
)
return resp
Improve and clarify large task error handling Previously we were catching one type of exception if something went wrong adding a notification to the queue for high volume services. In reality there are two types of exception so this adds a second handler to cover both. For context, this is code we changed experimentally as part of the upgrade to Celery 5 [1]. At the time we didn't check how the new exception compared to the old one. It turns out they behaved the same and we were always vulnerable to the scenario now covered by the second exception, where the behaviour has changed in Celery 5 - testing with a large task invocation gives... Before (Celery 3, large-ish task): 'process_job.apply_async(["a" * 200000])'... boto.exception.SQSError: SQSError: 400 Bad Request <?xml version="1.0"?><ErrorResponse xmlns="http://queue.amazonaws.com/doc/2012-11-05/"><Error><Type>Sender</Type><Code>InvalidParameterValue</Code><Message>One or more parameters are invalid. Reason: Message must be shorter than 262144 bytes.</Message><Detail/></Error><RequestId>96162552-cd96-5a14-b3a5-7f503300a662</RequestId></ErrorResponse> Before (Celery 3, very large task): <hangs forever> After (Celery 5, large-ish task): botocore.exceptions.ClientError: An error occurred (InvalidParameterValue) when calling the SendMessage operation: One or more parameters are invalid. Reason: Message must be shorter than 262144 bytes. After (Celery 5, very large task): botocore.parsers.ResponseParserError: Unable to parse response (syntax error: line 1, column 0), invalid XML received. Further retries may succeed: b'HTTP content length exceeded 1662976 bytes.' [1]: https://github.com/alphagov/notifications-api/pull/3355/commits/29c92a9e54fe406f9490f6c89bd047832f75ab5f
2021-11-08 10:38:53 +00:00
except (botocore.exceptions.ClientError, botocore.parsers.ResponseParserError):
# If SQS cannot put the task on the queue, it's probably because the notification body was too long and it
# went over SQS's 256kb message limit. If the body is very large, it may exceed the HTTP max content length;
# the exception we get here isn't handled correctly by botocore - we get a ResponseParserError instead.
2022-09-28 16:27:37 -04:00
# Hopefully this is no longer an issue with Redis as celery's backing store
current_app.logger.info(
2023-08-29 14:54:30 -07:00
f"Notification {notification_id} failed to save to high volume queue. Using normal flow instead"
)
persist_notification(
notification_id=notification_id,
template_id=template.id,
template_version=template.version,
recipient=form_send_to,
service=service,
personalisation=personalisation,
notification_type=notification_type,
2020-06-26 14:23:25 +01:00
api_key_id=api_user.id,
key_type=api_user.key_type,
2023-08-29 14:54:30 -07:00
client_reference=form.get("reference", None),
2017-11-23 14:55:49 +00:00
simulated=simulated,
reply_to_text=reply_to_text,
2023-08-29 14:54:30 -07:00
document_download_count=document_download_count,
)
if not simulated:
queue_name = (
QueueNames.PRIORITY
if template_process_type == TemplateProcessType.PRIORITY
else None
)
send_notification_to_queue_detached(
2020-06-26 14:23:25 +01:00
key_type=api_user.key_type,
notification_type=notification_type,
notification_id=notification_id,
2023-08-29 14:54:30 -07:00
queue=queue_name,
)
else:
2023-08-29 14:54:30 -07:00
current_app.logger.debug(
"POST simulated notification for id: {}".format(notification_id)
)
return resp
def save_email_or_sms_to_queue(
*,
notification_id,
form,
notification_type,
api_key,
template,
service_id,
personalisation,
document_download_count,
2023-08-29 14:54:30 -07:00
reply_to_text=None,
):
data = {
"id": notification_id,
"template_id": str(template.id),
"template_version": template.version,
2024-04-01 15:12:33 -07:00
"to": (
form["email_address"]
if notification_type == NotificationType.EMAIL
else form["phone_number"]
),
"service_id": str(service_id),
"personalisation": personalisation,
"notification_type": notification_type,
"api_key_id": str(api_key.id),
"key_type": api_key.key_type,
2023-08-29 14:54:30 -07:00
"client_reference": form.get("reference", None),
"reply_to_text": reply_to_text,
"document_download_count": document_download_count,
"status": NotificationStatus.CREATED,
2024-05-23 13:59:51 -07:00
"created_at": utc_now().strftime(DATETIME_FORMAT),
}
2023-08-29 14:54:30 -07:00
encrypted = encryption.encrypt(data)
if notification_type == NotificationType.EMAIL:
save_api_email.apply_async([encrypted], queue=QueueNames.SAVE_API_EMAIL)
elif notification_type == NotificationType.SMS:
save_api_sms.apply_async([encrypted], queue=QueueNames.SAVE_API_SMS)
return Notification(**data)
def process_document_uploads(personalisation_data, service, simulated=False):
"""
Returns modified personalisation dict and a count of document uploads. If there are no document uploads, returns
a count of `None` rather than `0`.
"""
2023-08-29 14:54:30 -07:00
file_keys = [
k
for k, v in (personalisation_data or {}).items()
if isinstance(v, dict) and "file" in v
]
if not file_keys:
return personalisation_data, None
personalisation_data = personalisation_data.copy()
check_if_service_can_send_files_by_email(
service_contact_link=authenticated_service.contact_link,
2023-08-29 14:54:30 -07:00
service_id=authenticated_service.id,
)
for key in file_keys:
if simulated:
2023-08-29 14:54:30 -07:00
personalisation_data[key] = (
document_download_client.get_upload_url(service.id) + "/test-document"
)
else:
try:
personalisation_data[key] = document_download_client.upload_document(
2023-08-29 14:54:30 -07:00
service.id,
personalisation_data[key]["file"],
personalisation_data[key].get("is_csv"),
)
except DocumentDownloadError as e:
raise BadRequestError(message=e.message, status_code=e.status_code)
return personalisation_data, len(file_keys)
def get_reply_to_text(notification_type, form, template):
reply_to = None
if notification_type == NotificationType.EMAIL:
service_email_reply_to_id = form.get("email_reply_to_id", None)
2023-08-29 14:54:30 -07:00
reply_to = (
check_service_email_reply_to_id(
str(authenticated_service.id),
service_email_reply_to_id,
notification_type,
)
or template.reply_to_text
)
elif notification_type == NotificationType.SMS:
service_sms_sender_id = form.get("sms_sender_id", None)
sms_sender_id = check_service_sms_sender_id(
str(authenticated_service.id), service_sms_sender_id, notification_type
)
if sms_sender_id:
reply_to = try_validate_and_format_phone_number(sms_sender_id)
else:
reply_to = template.reply_to_text
2017-11-23 14:55:49 +00:00
return reply_to
def create_response_for_post_notification(
notification_id,
client_reference,
template_id,
template_version,
service_id,
notification_type,
reply_to,
2023-08-29 14:54:30 -07:00
template_with_content,
):
if notification_type == NotificationType.SMS:
create_resp_partial = functools.partial(
create_post_sms_response_from_notification,
from_number=reply_to,
)
elif notification_type == NotificationType.EMAIL:
create_resp_partial = functools.partial(
create_post_email_response_from_notification,
subject=template_with_content.subject,
2023-08-29 14:54:30 -07:00
email_from="{}@{}".format(
authenticated_service.email_from,
current_app.config["NOTIFY_EMAIL_DOMAIN"],
),
)
resp = create_resp_partial(
2023-08-29 14:54:30 -07:00
notification_id,
client_reference,
template_id,
template_version,
service_id,
url_root=request.url_root,
content=template_with_content.content_with_placeholders_filled_in,
)
return resp