Skip to content

Commit

Permalink
feat: send enrollment/fulfillment failure email on unsupported course…
Browse files Browse the repository at this point in the history
… mode (#302)

* chore: added logs to note time for commercetools calls (#304)

* feat: add tests

* fix: docs error

* fix: quality error

* fix: handle review comments

* fix: handle final review

---------

Co-authored-by: NoyanAziz <[email protected]>
  • Loading branch information
syedsajjadkazmii and NoyanAziz authored Dec 4, 2024
1 parent f436499 commit 0522131
Show file tree
Hide file tree
Showing 13 changed files with 208 additions and 9 deletions.
3 changes: 3 additions & 0 deletions commerce_coordinator/apps/commercetools/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ class OrderFulfillViewInputSerializer(CoordinatorSerializer):
line_item_state_id = serializers.CharField(allow_null=False)
edx_lms_user_id = serializers.IntegerField(allow_null=False)
message_id = serializers.CharField(allow_null=False)
course_title = serializers.CharField(allow_null=False)
user_first_name = serializers.CharField(allow_null=False)
user_email = serializers.EmailField(allow_null=False)


class OrderReturnedViewMessageLineItemReturnItemSerializer(CoordinatorSerializer):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ def fulfill_order_placed_message_signal_task(
'course_mode': get_course_mode_from_ct_order(item),
'item_quantity': item.quantity,
'line_item_state_id': line_item_state_id,
'message_id': message_id
'message_id': message_id,
'user_first_name': customer.first_name,
'user_email': customer.email,
'course_title': item.name.get('en-US', '')
})

# the following throws and thus doesn't need to be a conditional
Expand Down
1 change: 1 addition & 0 deletions commerce_coordinator/apps/commercetools/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ def gen_example_customer() -> CTCustomer:

def gen_customer(email: str, un: str):
return CTCustomer(
first_name='John',
email=email,
custom=CTCustomFields(
type=CTTypeReference(
Expand Down
55 changes: 55 additions & 0 deletions commerce_coordinator/apps/commercetools/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
has_full_refund_transaction,
has_refund_transaction,
send_order_confirmation_email,
send_unsupported_mode_fulfillment_error_email,
translate_stripe_refund_status_to_transaction_status
)

Expand Down Expand Up @@ -126,6 +127,60 @@ def test_send_order_confirmation_email_failure(self, mock_logger, mock_get_braze
mock_logger.assert_called_once_with('Encountered exception sending Order confirmation email. '
'Exception: Error sending Braze email')

@override_settings(
BRAZE_API_KEY="braze_api_key",
BRAZE_API_SERVER="braze_api_server",
BRAZE_CT_FULFILLMENT_UNSUPPORTED_MODE_ERROR_CANVAS_ID="dummy_canvas"
)
@patch('commerce_coordinator.apps.commercetools.utils.get_braze_client')
def test_send_unsupported_mode_fulfillment_error_email_success(self, mock_get_braze_client):
mock_braze_client = Mock()
mock_get_braze_client.return_value = mock_braze_client

canvas_entry_properties = {}
lms_user_id = 'user123'
lms_user_email = '[email protected]'

with patch.object(mock_braze_client, 'send_canvas_message') as mock_send_canvas_message:
send_unsupported_mode_fulfillment_error_email(
lms_user_id, lms_user_email, canvas_entry_properties
)

mock_send_canvas_message.assert_called_once_with(
canvas_id='dummy_canvas',
recipients=[{"external_user_id": lms_user_id, "attributes": {"email": lms_user_email}}],
canvas_entry_properties=canvas_entry_properties,
)

@override_settings(
BRAZE_API_KEY="braze_api_key",
BRAZE_API_SERVER="braze_api_server",
BRAZE_CT_FULFILLMENT_UNSUPPORTED_MODE_ERROR_CANVAS_ID="dummy_canvas"
)
@patch('commerce_coordinator.apps.commercetools.utils.get_braze_client')
@patch('commerce_coordinator.apps.commercetools.utils.logger.exception')
def test_send_unsupported_mode_fulfillment_error_email_failure(self, mock_logger, mock_get_braze_client):
mock_braze_client = Mock()
mock_get_braze_client.return_value = mock_braze_client

canvas_entry_properties = {}
lms_user_id = 'user123'
lms_user_email = '[email protected]'

with patch.object(mock_braze_client, 'send_canvas_message') as mock_send_canvas_message:
mock_send_canvas_message.side_effect = Exception('Error sending Braze email')
send_unsupported_mode_fulfillment_error_email(
lms_user_id, lms_user_email, canvas_entry_properties
)

mock_send_canvas_message.assert_called_once_with(
canvas_id='dummy_canvas',
recipients=[{"external_user_id": lms_user_id, "attributes": {"email": lms_user_email}}],
canvas_entry_properties=canvas_entry_properties,
)
mock_logger.assert_called_once_with('Encountered exception sending Fulfillment unsupported mode error '
'email. Exception: Error sending Braze email')

def test_extract_ct_product_information_for_braze_canvas(self):
order = gen_order(EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD['order_number'])
line_item = order.line_items[0]
Expand Down
21 changes: 21 additions & 0 deletions commerce_coordinator/apps/commercetools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ def send_order_confirmation_email(
logger.exception(f"Encountered exception sending Order confirmation email. Exception: {exc}")


def send_unsupported_mode_fulfillment_error_email(
lms_user_id, lms_user_email, canvas_entry_properties
):
""" Sends fulfillment error email via Braze. """
recipients = [{"external_user_id": lms_user_id, "attributes": {
"email": lms_user_email,
}}]
canvas_id = settings.BRAZE_CT_FULFILLMENT_UNSUPPORTED_MODE_ERROR_CANVAS_ID

try:
braze_client = get_braze_client()
if braze_client:
braze_client.send_canvas_message(
canvas_id=canvas_id,
recipients=recipients,
canvas_entry_properties=canvas_entry_properties,
)
except Exception as exc: # pylint: disable=broad-exception-caught
logger.exception(f"Encountered exception sending Fulfillment unsupported mode error email. Exception: {exc}")


def format_amount_for_braze_canvas(centAmount):
"""
Utility to convert amount to dollar with 2 decimals percision. Also adds the Dollar signs to resulting value.
Expand Down
5 changes: 4 additions & 1 deletion commerce_coordinator/apps/lms/signal_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ def fulfill_order_placed_send_enroll_in_course(**kwargs):
line_item_id=kwargs['line_item_id'],
item_quantity=kwargs['item_quantity'],
line_item_state_id=kwargs['line_item_state_id'],
message_id=kwargs['message_id']
message_id=kwargs['message_id'],
user_first_name=kwargs['user_first_name'],
user_email=kwargs['user_email'],
course_title=kwargs['course_title']
)
return async_result.id
62 changes: 59 additions & 3 deletions commerce_coordinator/apps/lms/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,76 @@
LMS Celery tasks
"""

import json
from datetime import datetime

from celery import shared_task
from celery import Task, shared_task
from celery.utils.log import get_task_logger
from django.contrib.auth import get_user_model
from requests import RequestException

from commerce_coordinator.apps.commercetools.catalog_info.constants import TwoUKeys
from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient
from commerce_coordinator.apps.commercetools.utils import send_unsupported_mode_fulfillment_error_email
from commerce_coordinator.apps.lms.clients import LMSAPIClient

# Use the special Celery logger for our tasks
logger = get_task_logger(__name__)
User = get_user_model()


@shared_task(bind=True, autoretry_for=(RequestException,), retry_kwargs={'max_retries': 5, 'countdown': 3})
class CourseEnrollTaskAfterReturn(Task): # pylint: disable=abstract-method
"""
Base class for fulfill_order_placed_send_enroll_in_course_task
"""

def on_failure(self, exc, task_id, args, kwargs, einfo):
edx_lms_user_id = kwargs.get('edx_lms_user_id')
user_email = kwargs.get('user_email')
order_number = kwargs.get('order_number')
user_first_name = kwargs.get('user_first_name')
course_title = kwargs.get('course_title')

error_message = (
json.loads(exc.response.text).get('message', '')
if isinstance(exc, RequestException) and exc.response is not None
else str(exc)
)

logger.error(
f"Post-purchase fulfillment task {self.name} failed after max "
f"retries with the error message: {error_message} "
f"for user with user Id: {edx_lms_user_id}, email: {user_email}, "
f"order number: {order_number}, and course title: {course_title}"
)

# This error is returned from LMS if the course mode is unsupported
# https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/enrollments/views.py#L870
course_mode_expired_error = "course mode is expired or otherwise unavailable for course run"

if course_mode_expired_error in error_message:
logger.info(
f"Sending unsupported course mode fulfillment error email "
f"for the user with user ID: {edx_lms_user_id}, email: {user_email}, "
f"order number: {order_number}, and course title: {course_title}"
)

canvas_entry_properties = {
'order_number': order_number,
'product_type': 'course', # TODO: Fetch product type from commercetools product object
'product_name': course_title,
'first_name': user_first_name,
}
# Send failure notification email
send_unsupported_mode_fulfillment_error_email(edx_lms_user_id, user_email, canvas_entry_properties)


@shared_task(
bind=True,
autoretry_for=(RequestException,),
retry_kwargs={'max_retries': 5, 'countdown': 3},
base=CourseEnrollTaskAfterReturn,
)
def fulfill_order_placed_send_enroll_in_course_task(
self,
course_id,
Expand All @@ -34,7 +87,10 @@ def fulfill_order_placed_send_enroll_in_course_task(
line_item_id,
item_quantity,
line_item_state_id,
message_id
message_id,
user_first_name, # pylint: disable=unused-argument
user_email, # pylint: disable=unused-argument
course_title # pylint: disable=unused-argument
):
"""
Celery task for order placed fulfillment and enrollment via LMS Enrollment API.
Expand Down
5 changes: 4 additions & 1 deletion commerce_coordinator/apps/lms/tests/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@
'line_item_id': '822d77c4-00a6-4fb9-909b-094ef0b8c4b9',
'item_quantity': 1,
'line_item_state_id': '8f2e888e-9777-4557-9a7f-c649153770c2',
'message_id': '1063f19c-08f3-41a4-a952-a8577374373c'
'message_id': '1063f19c-08f3-41a4-a952-a8577374373c',
'user_first_name': 'test',
'user_email': '[email protected]',
'course_title': 'Demonstration Course',
}

EXAMPLE_FULFILLMENT_REQUEST_PAYLOAD = {
Expand Down
5 changes: 4 additions & 1 deletion commerce_coordinator/apps/lms/tests/test_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ class FulfillOrderPlacedSendEnrollInCourseTest(CoordinatorSignalReceiverTestCase
'line_item_id': 11,
'item_quantity': 1,
'line_item_state_id': 12,
'message_id': 13
'message_id': 13,
'user_first_name': 14,
'user_email': 15,
'course_title': 16,
}

def test_correct_arguments_passed(self, mock_task):
Expand Down
46 changes: 44 additions & 2 deletions commerce_coordinator/apps/lms/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

import logging
from unittest.mock import patch, sentinel
from unittest.mock import Mock, patch, sentinel

from django.test import TestCase
from requests import RequestException
Expand Down Expand Up @@ -49,7 +49,10 @@ def unpack_for_uut(values):
values['line_item_id'],
values['item_quantity'],
values['line_item_state_id'],
values['message_id']
values['message_id'],
values['user_first_name'],
values['user_email'],
values['course_title']
)

def setUp(self):
Expand Down Expand Up @@ -130,3 +133,42 @@ def test_retry_logic(self, mock_ct_get_order, mock_ct_get_state, mock_client):

mock_ct_get_state.assert_called_with(TwoUKeys.FAILURE_FULFILMENT_STATE)
mock_ct_get_order.assert_called_with(EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD.get('order_id'))

@patch('commerce_coordinator.apps.lms.tasks.send_unsupported_mode_fulfillment_error_email')
@patch.object(fulfill_order_placed_send_enroll_in_course_task, 'max_retries', 5)
def test_fulfillment_error_email_is_sent_on_failure(
self, mock_send_email, mock_client
): # pylint: disable=unused-argument
"""
Test that `on_failure` sends the appropriate failure email.
"""
mock_response = Mock()
mock_response.text = '{"message": "course mode is expired or otherwise unavailable for course run"}'
exception = RequestException("400 Bad Request")
exception.response = mock_response

exc = exception
task_id = "test_task_id"
args = []
kwargs = EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD
einfo = Mock()

fulfill_order_placed_send_enroll_in_course_task.push_request(retries=5)
fulfill_order_placed_send_enroll_in_course_task.on_failure(
exc=exc,
task_id=task_id,
args=args,
kwargs=kwargs,
einfo=einfo
)

mock_send_email.assert_called_once_with(
EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD['edx_lms_user_id'],
EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD['user_email'],
{
'order_number': EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD['order_number'],
'product_type': 'course',
'product_name': EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD['course_title'],
'first_name': EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD['user_first_name'],
}
)
1 change: 1 addition & 0 deletions commerce_coordinator/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ def root(*path_fragments):
BRAZE_API_KEY = None
BRAZE_API_SERVER = None
BRAZE_CT_ORDER_CONFIRMATION_CANVAS_ID = ''
BRAZE_CT_FULFILLMENT_UNSUPPORTED_MODE_ERROR_CANVAS_ID = ''

# SEGMENT WRITE KEY
SEGMENT_KEY = None
Expand Down
1 change: 1 addition & 0 deletions commerce_coordinator/settings/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
'host.docker.internal',
'localhost',
'.ngrok-free.app',
'.share.zrok.io',
)

INSTALLED_APPS += (
Expand Down
7 changes: 7 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,10 @@ def setup(app):
"""Sphinx extension: run sphinx-apidoc."""
event = 'builder-inited'
app.connect(event, on_init)

# celery.Task has some roles inside the library that are not recognized by Sphinx
# and causing errors, so we add them here to avoid the errors.
rst_prolog = """
.. role:: setting
.. role:: sig
"""

0 comments on commit 0522131

Please sign in to comment.