diff --git a/lms/djangoapps/commerce/signals.py b/lms/djangoapps/commerce/signals.py index 446815132a..0d54ab4e7d 100644 --- a/lms/djangoapps/commerce/signals.py +++ b/lms/djangoapps/commerce/signals.py @@ -2,7 +2,7 @@ Signal handling functions for use with external commerce service. """ import logging -from urlparse import urlparse +from urlparse import urljoin from django.conf import settings from django.core.mail import EmailMultiAlternatives @@ -39,7 +39,7 @@ def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, **kw # trapping the Exception and logging an error. log.exception( "Unexpected exception while attempting to initiate refund for user [%s], course [%s]", - course_enrollment.user, + course_enrollment.user.id, course_enrollment.course_id, ) @@ -90,7 +90,7 @@ def refund_seat(course_enrollment, request_user): # support the case of a non-superusers initiating a refund on # behalf of another user. log.warning("User [%s] was not authorized to initiate a refund for user [%s] " - "upon unenrollment from course [%s]", request_user, unenrolled_user, course_key_str) + "upon unenrollment from course [%s]", request_user.id, unenrolled_user.id, course_key_str) return [] else: # no other error is anticipated, so re-raise the Exception @@ -104,11 +104,27 @@ def refund_seat(course_enrollment, request_user): course_key_str, refund_ids, ) - try: - send_refund_notification(course_enrollment, refund_ids) - except: # pylint: disable=bare-except - # don't break, just log a warning - log.warning("Could not send email notification for refund.", exc_info=True) + + # XCOM-371: this is a temporary measure to suppress refund-related email + # notifications to students and support@) for free enrollments. This + # condition should be removed when the CourseEnrollment.refundable() logic + # is updated to be more correct, or when we implement better handling (and + # notifications) in Otto for handling reversal of $0 transactions. + if course_enrollment.mode != 'verified': + # 'verified' is the only enrollment mode that should presently + # result in opening a refund request. + log.info( + "Skipping refund email notification for non-verified mode for user [%s], course [%s], mode: [%s]", + course_enrollment.user.id, + course_enrollment.course_id, + course_enrollment.mode, + ) + else: + try: + send_refund_notification(course_enrollment, refund_ids) + except: # pylint: disable=bare-except + # don't break, just log a warning + log.warning("Could not send email notification for refund.", exc_info=True) else: # no refundable orders were found. log.debug("No refund opened for user [%s], course [%s]", unenrolled_user.id, course_key_str) @@ -131,14 +147,14 @@ def send_refund_notification(course_enrollment, refund_ids): for_user = course_enrollment.user subject = _("[Refund] User-Requested Refund") message = _( - "A refund request has been initiated for {username} ({email}). To process this request, please visit the link(s) below." # pylint: disable=line-too-long + "A refund request has been initiated for {username} ({email}). " + "To process this request, please visit the link(s) below." ).format(username=for_user.username, email=for_user.email) - # TODO: this manipulation of API url is temporary, pending the introduction - # of separate configuration items for the service's base url and api path. - ecommerce_url = '://'.join(urlparse(settings.ECOMMERCE_API_URL)[:2]) - - refund_urls = ["{}/dashboard/refunds/{}/".format(ecommerce_url, refund_id) for refund_id in refund_ids] + refund_urls = [ + urljoin(settings.ECOMMERCE_PUBLIC_URL_ROOT, '/dashboard/refunds/{}/'.format(refund_id)) + for refund_id in refund_ids + ] text_body = '\r\n'.join([message] + refund_urls + ['']) refund_links = ['{0}'.format(url) for url in refund_urls] html_body = '

{}

'.format('
'.join([message] + refund_links)) diff --git a/lms/djangoapps/commerce/tests/__init__.py b/lms/djangoapps/commerce/tests/__init__.py index 0601b0cc17..4af326a662 100644 --- a/lms/djangoapps/commerce/tests/__init__.py +++ b/lms/djangoapps/commerce/tests/__init__.py @@ -12,7 +12,8 @@ from commerce import ecommerce_api_client from student.tests.factories import UserFactory -TEST_API_URL = 'http://example.com/api' +TEST_PUBLIC_URL_ROOT = 'http://www.example.com' +TEST_API_URL = 'http://www-internal.example.com/api' TEST_API_SIGNING_KEY = 'edx' TEST_BASKET_ID = 7 TEST_ORDER_NUMBER = '100004' diff --git a/lms/djangoapps/commerce/tests/test_signals.py b/lms/djangoapps/commerce/tests/test_signals.py index 0d354622a0..85eb7cb591 100644 --- a/lms/djangoapps/commerce/tests/test_signals.py +++ b/lms/djangoapps/commerce/tests/test_signals.py @@ -3,23 +3,25 @@ Tests for signal handling in commerce djangoapp. """ from django.test import TestCase from django.test.utils import override_settings -from urlparse import urlparse +from course_modes.models import CourseMode +import ddt import mock from opaque_keys.edx.keys import CourseKey from student.models import UNENROLL_DONE from student.tests.factories import UserFactory, CourseEnrollmentFactory from commerce.signals import refund_seat, send_refund_notification -from commerce.tests import TEST_API_URL, TEST_API_SIGNING_KEY +from commerce.tests import TEST_PUBLIC_URL_ROOT, TEST_API_URL, TEST_API_SIGNING_KEY from commerce.tests.mocks import mock_create_refund -# TODO: this is temporary. See the corresponding TODO in signals.send_refund_notification -TEST_BASE_URL = '://'.join(urlparse(TEST_API_URL)[:2]) - - -@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY) +@ddt.ddt +@override_settings( + ECOMMERCE_PUBLIC_URL_ROOT=TEST_PUBLIC_URL_ROOT, + ECOMMERCE_API_URL=TEST_API_URL, + ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY, +) class TestRefundSignal(TestCase): """ Exercises logic triggered by the UNENROLL_DONE signal. @@ -32,6 +34,7 @@ class TestRefundSignal(TestCase): self.course_enrollment = CourseEnrollmentFactory( user=self.student, course_id=CourseKey.from_string('course-v1:org+course+run'), + mode=CourseMode.VERIFIED, ) self.course_enrollment.refundable = mock.Mock(return_value=True) @@ -42,7 +45,11 @@ class TestRefundSignal(TestCase): """ UNENROLL_DONE.send(sender=None, course_enrollment=self.course_enrollment, skip_refund=skip_refund) - @override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None) + @override_settings( + ECOMMERCE_PUBLIC_URL_ROOT=None, + ECOMMERCE_API_URL=None, + ECOMMERCE_API_SIGNING_KEY=None, + ) def test_no_service(self): """ Ensure that the receiver quietly bypasses attempts to initiate @@ -108,7 +115,7 @@ class TestRefundSignal(TestCase): Ensure that expected authorization issues are logged as warnings. """ with mock_create_refund(status=403): - refund_seat(self.course_enrollment, None) + refund_seat(self.course_enrollment, UserFactory()) self.assertTrue(mock_log_warning.called) @mock.patch('commerce.signals.log.exception') @@ -122,14 +129,14 @@ class TestRefundSignal(TestCase): self.assertTrue(mock_log_exception.called) @mock.patch('commerce.signals.send_refund_notification') - def test_notification(self, mock_send_notificaton): + def test_notification(self, mock_send_notification): """ Ensure the notification function is triggered when refunds are initiated """ with mock_create_refund(status=200, response=[1, 2, 3]): self.send_signal() - self.assertTrue(mock_send_notificaton.called) + self.assertTrue(mock_send_notification.called) @mock.patch('commerce.signals.send_refund_notification') def test_notification_no_refund(self, mock_send_notification): @@ -141,6 +148,27 @@ class TestRefundSignal(TestCase): self.send_signal() self.assertFalse(mock_send_notification.called) + @mock.patch('commerce.signals.send_refund_notification') + @ddt.data( + CourseMode.HONOR, + CourseMode.PROFESSIONAL, + CourseMode.AUDIT, + CourseMode.NO_ID_PROFESSIONAL_MODE, + CourseMode.CREDIT_MODE, + ) + def test_notification_not_verified(self, mode, mock_send_notification): + """ + Ensure the notification function is NOT triggered when the + unenrollment is for any mode other than verified (i.e. any mode other + than one for which refunds are presently supported). See the + TODO associated with XCOM-371 in the signals module in the commerce + package for more information. + """ + self.course_enrollment.mode = mode + with mock_create_refund(status=200, response=[1, 2, 3]): + self.send_signal() + self.assertFalse(mock_send_notification.called) + @mock.patch('commerce.signals.send_refund_notification', side_effect=Exception("Splat!")) @mock.patch('commerce.signals.log.warning') def test_notification_error(self, mock_log_warning, mock_send_notification): @@ -185,14 +213,15 @@ class TestRefundSignal(TestCase): ) text_body = mock_email_class.call_args[0][1] # check for a URL for each refund - for exp in [r'{0}/dashboard/refunds/{1}/'.format(TEST_BASE_URL, refund_id) for refund_id in refund_ids]: + for exp in [r'{0}/dashboard/refunds/{1}/'.format(TEST_PUBLIC_URL_ROOT, refund_id) + for refund_id in refund_ids]: self.assertRegexpMatches(text_body, exp) # check HTML content self.assertEqual(mock_message.attach_alternative.call_args[0], (mock.ANY, "text/html")) html_body = mock_message.attach_alternative.call_args[0][0] # check for a link to each refund - for exp in [r'a href="{0}/dashboard/refunds/{1}/"'.format(TEST_BASE_URL, refund_id) + for exp in [r'a href="{0}/dashboard/refunds/{1}/"'.format(TEST_PUBLIC_URL_ROOT, refund_id) for refund_id in refund_ids]: self.assertRegexpMatches(html_body, exp) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 69df5d519f..7d8418cf19 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -583,6 +583,7 @@ CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS) ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_BEACON_SAMPLE_RATE) ##### ECOMMERCE API CONFIGURATION SETTINGS ##### +ECOMMERCE_PUBLIC_URL_ROOT = ENV_TOKENS.get('ECOMMERCE_PUBLIC_URL_ROOT', ECOMMERCE_PUBLIC_URL_ROOT) ECOMMERCE_API_URL = ENV_TOKENS.get('ECOMMERCE_API_URL', ECOMMERCE_API_URL) ECOMMERCE_API_SIGNING_KEY = AUTH_TOKENS.get('ECOMMERCE_API_SIGNING_KEY', ECOMMERCE_API_SIGNING_KEY) ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TIMEOUT) diff --git a/lms/envs/common.py b/lms/envs/common.py index baa3b8f4c2..077c5a2647 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2317,6 +2317,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { } # E-Commerce API Configuration +ECOMMERCE_PUBLIC_URL_ROOT = None ECOMMERCE_API_URL = None ECOMMERCE_API_SIGNING_KEY = None ECOMMERCE_API_TIMEOUT = 5