Commit b5c6eb78 by Renzo Lucioni

Merge pull request #8255 from edx/patch/2015-05-29

patch/2015-05-29
parents e424ce8a 18cbc0a5
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Signal handling functions for use with external commerce service. Signal handling functions for use with external commerce service.
""" """
import logging import logging
from urlparse import urlparse from urlparse import urljoin
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
...@@ -39,7 +39,7 @@ def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, **kw ...@@ -39,7 +39,7 @@ def handle_unenroll_done(sender, course_enrollment=None, skip_refund=False, **kw
# trapping the Exception and logging an error. # trapping the Exception and logging an error.
log.exception( log.exception(
"Unexpected exception while attempting to initiate refund for user [%s], course [%s]", "Unexpected exception while attempting to initiate refund for user [%s], course [%s]",
course_enrollment.user, course_enrollment.user.id,
course_enrollment.course_id, course_enrollment.course_id,
) )
...@@ -90,7 +90,7 @@ def refund_seat(course_enrollment, request_user): ...@@ -90,7 +90,7 @@ def refund_seat(course_enrollment, request_user):
# support the case of a non-superusers initiating a refund on # support the case of a non-superusers initiating a refund on
# behalf of another user. # behalf of another user.
log.warning("User [%s] was not authorized to initiate a refund for user [%s] " 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 [] return []
else: else:
# no other error is anticipated, so re-raise the Exception # no other error is anticipated, so re-raise the Exception
...@@ -104,11 +104,27 @@ def refund_seat(course_enrollment, request_user): ...@@ -104,11 +104,27 @@ def refund_seat(course_enrollment, request_user):
course_key_str, course_key_str,
refund_ids, refund_ids,
) )
try:
send_refund_notification(course_enrollment, refund_ids) # XCOM-371: this is a temporary measure to suppress refund-related email
except: # pylint: disable=bare-except # notifications to students and support@) for free enrollments. This
# don't break, just log a warning # condition should be removed when the CourseEnrollment.refundable() logic
log.warning("Could not send email notification for refund.", exc_info=True) # 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: else:
# no refundable orders were found. # no refundable orders were found.
log.debug("No refund opened for user [%s], course [%s]", unenrolled_user.id, course_key_str) 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): ...@@ -131,14 +147,14 @@ def send_refund_notification(course_enrollment, refund_ids):
for_user = course_enrollment.user for_user = course_enrollment.user
subject = _("[Refund] User-Requested Refund") subject = _("[Refund] User-Requested Refund")
message = _( 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) ).format(username=for_user.username, email=for_user.email)
# TODO: this manipulation of API url is temporary, pending the introduction refund_urls = [
# of separate configuration items for the service's base url and api path. urljoin(settings.ECOMMERCE_PUBLIC_URL_ROOT, '/dashboard/refunds/{}/'.format(refund_id))
ecommerce_url = '://'.join(urlparse(settings.ECOMMERCE_API_URL)[:2]) for refund_id in refund_ids
]
refund_urls = ["{}/dashboard/refunds/{}/".format(ecommerce_url, refund_id) for refund_id in refund_ids]
text_body = '\r\n'.join([message] + refund_urls + ['']) text_body = '\r\n'.join([message] + refund_urls + [''])
refund_links = ['<a href="{0}">{0}</a>'.format(url) for url in refund_urls] refund_links = ['<a href="{0}">{0}</a>'.format(url) for url in refund_urls]
html_body = '<p>{}</p>'.format('<br>'.join([message] + refund_links)) html_body = '<p>{}</p>'.format('<br>'.join([message] + refund_links))
......
...@@ -12,7 +12,8 @@ from commerce import ecommerce_api_client ...@@ -12,7 +12,8 @@ from commerce import ecommerce_api_client
from student.tests.factories import UserFactory 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_API_SIGNING_KEY = 'edx'
TEST_BASKET_ID = 7 TEST_BASKET_ID = 7
TEST_ORDER_NUMBER = '100004' TEST_ORDER_NUMBER = '100004'
......
...@@ -3,23 +3,25 @@ Tests for signal handling in commerce djangoapp. ...@@ -3,23 +3,25 @@ Tests for signal handling in commerce djangoapp.
""" """
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from urlparse import urlparse
from course_modes.models import CourseMode
import ddt
import mock import mock
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from student.models import UNENROLL_DONE from student.models import UNENROLL_DONE
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from commerce.signals import refund_seat, send_refund_notification 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 from commerce.tests.mocks import mock_create_refund
# TODO: this is temporary. See the corresponding TODO in signals.send_refund_notification @ddt.ddt
TEST_BASE_URL = '://'.join(urlparse(TEST_API_URL)[:2]) @override_settings(
ECOMMERCE_PUBLIC_URL_ROOT=TEST_PUBLIC_URL_ROOT,
ECOMMERCE_API_URL=TEST_API_URL,
@override_settings(ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY) ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
)
class TestRefundSignal(TestCase): class TestRefundSignal(TestCase):
""" """
Exercises logic triggered by the UNENROLL_DONE signal. Exercises logic triggered by the UNENROLL_DONE signal.
...@@ -32,6 +34,7 @@ class TestRefundSignal(TestCase): ...@@ -32,6 +34,7 @@ class TestRefundSignal(TestCase):
self.course_enrollment = CourseEnrollmentFactory( self.course_enrollment = CourseEnrollmentFactory(
user=self.student, user=self.student,
course_id=CourseKey.from_string('course-v1:org+course+run'), course_id=CourseKey.from_string('course-v1:org+course+run'),
mode=CourseMode.VERIFIED,
) )
self.course_enrollment.refundable = mock.Mock(return_value=True) self.course_enrollment.refundable = mock.Mock(return_value=True)
...@@ -42,7 +45,11 @@ class TestRefundSignal(TestCase): ...@@ -42,7 +45,11 @@ class TestRefundSignal(TestCase):
""" """
UNENROLL_DONE.send(sender=None, course_enrollment=self.course_enrollment, skip_refund=skip_refund) 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): def test_no_service(self):
""" """
Ensure that the receiver quietly bypasses attempts to initiate Ensure that the receiver quietly bypasses attempts to initiate
...@@ -108,7 +115,7 @@ class TestRefundSignal(TestCase): ...@@ -108,7 +115,7 @@ class TestRefundSignal(TestCase):
Ensure that expected authorization issues are logged as warnings. Ensure that expected authorization issues are logged as warnings.
""" """
with mock_create_refund(status=403): with mock_create_refund(status=403):
refund_seat(self.course_enrollment, None) refund_seat(self.course_enrollment, UserFactory())
self.assertTrue(mock_log_warning.called) self.assertTrue(mock_log_warning.called)
@mock.patch('commerce.signals.log.exception') @mock.patch('commerce.signals.log.exception')
...@@ -122,14 +129,14 @@ class TestRefundSignal(TestCase): ...@@ -122,14 +129,14 @@ class TestRefundSignal(TestCase):
self.assertTrue(mock_log_exception.called) self.assertTrue(mock_log_exception.called)
@mock.patch('commerce.signals.send_refund_notification') @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 Ensure the notification function is triggered when refunds are
initiated initiated
""" """
with mock_create_refund(status=200, response=[1, 2, 3]): with mock_create_refund(status=200, response=[1, 2, 3]):
self.send_signal() self.send_signal()
self.assertTrue(mock_send_notificaton.called) self.assertTrue(mock_send_notification.called)
@mock.patch('commerce.signals.send_refund_notification') @mock.patch('commerce.signals.send_refund_notification')
def test_notification_no_refund(self, mock_send_notification): def test_notification_no_refund(self, mock_send_notification):
...@@ -141,6 +148,27 @@ class TestRefundSignal(TestCase): ...@@ -141,6 +148,27 @@ class TestRefundSignal(TestCase):
self.send_signal() self.send_signal()
self.assertFalse(mock_send_notification.called) 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.send_refund_notification', side_effect=Exception("Splat!"))
@mock.patch('commerce.signals.log.warning') @mock.patch('commerce.signals.log.warning')
def test_notification_error(self, mock_log_warning, mock_send_notification): def test_notification_error(self, mock_log_warning, mock_send_notification):
...@@ -185,14 +213,15 @@ class TestRefundSignal(TestCase): ...@@ -185,14 +213,15 @@ class TestRefundSignal(TestCase):
) )
text_body = mock_email_class.call_args[0][1] text_body = mock_email_class.call_args[0][1]
# check for a URL for each refund # 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) self.assertRegexpMatches(text_body, exp)
# check HTML content # check HTML content
self.assertEqual(mock_message.attach_alternative.call_args[0], (mock.ANY, "text/html")) self.assertEqual(mock_message.attach_alternative.call_args[0], (mock.ANY, "text/html"))
html_body = mock_message.attach_alternative.call_args[0][0] html_body = mock_message.attach_alternative.call_args[0][0]
# check for a link to each refund # 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]: for refund_id in refund_ids]:
self.assertRegexpMatches(html_body, exp) self.assertRegexpMatches(html_body, exp)
......
...@@ -583,6 +583,7 @@ CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS) ...@@ -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) ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_BEACON_SAMPLE_RATE)
##### ECOMMERCE API CONFIGURATION SETTINGS ##### ##### 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_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_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) ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TIMEOUT)
......
...@@ -2317,6 +2317,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ...@@ -2317,6 +2317,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
} }
# E-Commerce API Configuration # E-Commerce API Configuration
ECOMMERCE_PUBLIC_URL_ROOT = None
ECOMMERCE_API_URL = None ECOMMERCE_API_URL = None
ECOMMERCE_API_SIGNING_KEY = None ECOMMERCE_API_SIGNING_KEY = None
ECOMMERCE_API_TIMEOUT = 5 ECOMMERCE_API_TIMEOUT = 5
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment