Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
96ee90d5
Commit
96ee90d5
authored
Jul 06, 2015
by
Clinton Blackburn
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #8772 from edx/clintonb/refund-notification
Creating Zendesk refund notifications via API
parents
ad5a98e4
40282316
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
161 additions
and
71 deletions
+161
-71
lms/djangoapps/commerce/signals.py
+72
-26
lms/djangoapps/commerce/tests/__init__.py
+3
-4
lms/djangoapps/commerce/tests/test_signals.py
+86
-41
No files found.
lms/djangoapps/commerce/signals.py
View file @
96ee90d5
"""
Signal handling functions for use with external commerce service.
"""
import
json
import
logging
from
urlparse
import
urljoin
from
django.conf
import
settings
from
django.contrib.auth.models
import
AnonymousUser
from
django.core.mail
import
EmailMultiAlternatives
from
django.dispatch
import
receiver
from
django.utils.translation
import
ugettext
as
_
from
ecommerce_api_client.exceptions
import
HttpClientError
import
requests
from
microsite_configuration
import
microsite
from
request_cache.middleware
import
RequestCache
from
student.models
import
UNENROLL_DONE
from
commerce
import
ecommerce_api_client
,
is_commerce_service_configured
log
=
logging
.
getLogger
(
__name__
)
@receiver
(
UNENROLL_DONE
)
def
handle_unenroll_done
(
sender
,
course_enrollment
=
None
,
skip_refund
=
False
,
**
kwargs
):
# pylint: disable=unused-argument
def
handle_unenroll_done
(
sender
,
course_enrollment
=
None
,
skip_refund
=
False
,
**
kwargs
):
# pylint: disable=unused-argument
"""
Signal receiver for unenrollments, used to automatically initiate refunds
when applicable.
...
...
@@ -140,33 +142,77 @@ def refund_seat(course_enrollment, request_user):
return
refund_ids
def
create_zendesk_ticket
(
requester_name
,
requester_email
,
subject
,
body
,
tags
=
None
):
""" Create a Zendesk ticket via API. """
if
not
(
settings
.
ZENDESK_URL
and
settings
.
ZENDESK_USER
and
settings
.
ZENDESK_API_KEY
):
log
.
debug
(
'Zendesk is not configured. Cannot create a ticket.'
)
return
# Copy the tags to avoid modifying the original list.
tags
=
list
(
tags
or
[])
tags
.
append
(
'LMS'
)
# Remove duplicates
tags
=
list
(
set
(
tags
))
data
=
{
'ticket'
:
{
'requester'
:
{
'name'
:
requester_name
,
'email'
:
requester_email
},
'subject'
:
subject
,
'comment'
:
{
'body'
:
body
},
'tags'
:
tags
}
}
# Encode the data to create a JSON payload
payload
=
json
.
dumps
(
data
)
# Set the request parameters
url
=
urljoin
(
settings
.
ZENDESK_URL
,
'/api/v2/tickets.json'
)
user
=
'{}/token'
.
format
(
settings
.
ZENDESK_USER
)
pwd
=
settings
.
ZENDESK_API_KEY
headers
=
{
'content-type'
:
'application/json'
}
try
:
response
=
requests
.
post
(
url
,
data
=
payload
,
auth
=
(
user
,
pwd
),
headers
=
headers
)
# Check for HTTP codes other than 201 (Created)
if
response
.
status_code
!=
201
:
log
.
error
(
u'Failed to create ticket. Status: [
%
d], Body: [
%
s]'
,
response
.
status_code
,
response
.
content
)
else
:
log
.
debug
(
'Successfully created ticket.'
)
except
Exception
:
# pylint: disable=broad-except
log
.
exception
(
'Failed to create ticket.'
)
return
def
generate_refund_notification_body
(
student
,
refund_ids
):
# pylint: disable=invalid-name
""" Returns a refund notification message body. """
msg
=
_
(
"A refund request has been initiated for {username} ({email}). "
"To process this request, please visit the link(s) below."
)
.
format
(
username
=
student
.
username
,
email
=
student
.
email
)
refund_urls
=
[
urljoin
(
settings
.
ECOMMERCE_PUBLIC_URL_ROOT
,
'/dashboard/refunds/{}/'
.
format
(
refund_id
))
for
refund_id
in
refund_ids
]
return
'{msg}
\n\n
{urls}'
.
format
(
msg
=
msg
,
urls
=
'
\n
'
.
join
(
refund_urls
))
def
send_refund_notification
(
course_enrollment
,
refund_ids
):
"""
Issue an email notification to the configured email recipient about a
newly-initiated refund request.
"""
Notify the support team of the refund request. """
tags
=
[
'auto_refund'
]
This function does not do any exception handling; callers are responsible
for capturing and recovering from any errors.
"""
if
microsite
.
is_request_in_microsite
():
# this is not presently supported with the external service.
raise
NotImplementedError
(
"Unable to send refund processing emails to microsite teams."
)
for_user
=
course_enrollment
.
user
student
=
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."
)
.
format
(
username
=
for_user
.
username
,
email
=
for_user
.
email
)
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
=
[
'<a href="{0}">{0}</a>'
.
format
(
url
)
for
url
in
refund_urls
]
html_body
=
'<p>{}</p>'
.
format
(
'<br>'
.
join
([
message
]
+
refund_links
))
email_message
=
EmailMultiAlternatives
(
subject
,
text_body
,
for_user
.
email
,
[
settings
.
PAYMENT_SUPPORT_EMAIL
])
email_message
.
attach_alternative
(
html_body
,
"text/html"
)
email_message
.
send
()
body
=
generate_refund_notification_body
(
student
,
refund_ids
)
requester_name
=
student
.
profile
.
name
or
student
.
username
create_zendesk_ticket
(
requester_name
,
student
.
email
,
subject
,
body
,
tags
)
lms/djangoapps/commerce/tests/__init__.py
View file @
96ee90d5
# -*- coding: utf-8 -*-
""" Commerce app tests package. """
import
json
from
django.test
import
TestCase
from
django.test.utils
import
override_settings
...
...
@@ -11,7 +10,7 @@ import mock
from
commerce
import
ecommerce_api_client
from
student.tests.factories
import
UserFactory
JSON
=
'application/json'
TEST_PUBLIC_URL_ROOT
=
'http://www.example.com'
TEST_API_URL
=
'http://www-internal.example.com/api'
TEST_API_SIGNING_KEY
=
'edx'
...
...
@@ -48,7 +47,7 @@ class EcommerceApiClientTest(TestCase):
httpretty
.
POST
,
'{}/baskets/1/'
.
format
(
TEST_API_URL
),
status
=
200
,
body
=
'{}'
,
adding_headers
=
{
'Content-Type'
:
'application/json'
}
adding_headers
=
{
'Content-Type'
:
JSON
}
)
mock_tracker
=
mock
.
Mock
()
mock_tracker
.
resolve_context
=
mock
.
Mock
(
return_value
=
{
'client_id'
:
self
.
TEST_CLIENT_ID
})
...
...
@@ -82,7 +81,7 @@ class EcommerceApiClientTest(TestCase):
httpretty
.
GET
,
'{}/baskets/1/order/'
.
format
(
TEST_API_URL
),
status
=
200
,
body
=
expected_content
,
adding_headers
=
{
'Content-Type'
:
'application/json'
},
adding_headers
=
{
'Content-Type'
:
JSON
},
)
actual_object
=
ecommerce_api_client
(
self
.
user
)
.
baskets
(
1
)
.
order
.
get
()
self
.
assertEqual
(
actual_object
,
{
u"result"
:
u"Préparatoire"
})
lms/djangoapps/commerce/tests/test_signals.py
View file @
96ee90d5
"""
Tests for signal handling in commerce djangoapp.
"""
import
base64
import
json
from
urlparse
import
urljoin
import
ddt
from
django.contrib.auth.models
import
AnonymousUser
from
django.test
import
TestCase
from
django.test.utils
import
override_settings
from
course_modes.models
import
CourseMode
import
ddt
import
httpretty
import
mock
from
opaque_keys.edx.keys
import
CourseKey
from
requests
import
Timeout
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_PUBLIC_URL_ROOT
,
TEST_API_URL
,
TEST_API_SIGNING_KEY
from
commerce.signals
import
(
refund_seat
,
send_refund_notification
,
generate_refund_notification_body
,
create_zendesk_ticket
)
from
commerce.tests
import
TEST_PUBLIC_URL_ROOT
,
TEST_API_URL
,
TEST_API_SIGNING_KEY
,
JSON
from
commerce.tests.mocks
import
mock_create_refund
from
course_modes.models
import
CourseMode
ZENDESK_URL
=
'http://zendesk.example.com/'
ZENDESK_USER
=
'test@example.com'
ZENDESK_API_KEY
=
'abc123'
@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
,
ECOMMERCE_API_URL
=
TEST_API_URL
,
ECOMMERCE_API_SIGNING_KEY
=
TEST_API_SIGNING_KEY
,
ZENDESK_URL
=
ZENDESK_URL
,
ZENDESK_USER
=
ZENDESK_USER
,
ZENDESK_API_KEY
=
ZENDESK_API_KEY
)
class
TestRefundSignal
(
TestCase
):
"""
...
...
@@ -197,40 +207,75 @@ class TestRefundSignal(TestCase):
with
self
.
assertRaises
(
NotImplementedError
):
send_refund_notification
(
self
.
course_enrollment
,
[
1
,
2
,
3
])
@override_settings
(
PAYMENT_SUPPORT_EMAIL
=
'payment@example.com'
)
@mock.patch
(
'commerce.signals.EmailMultiAlternatives'
)
def
test_notification_content
(
self
,
mock_email_class
):
def
test_send_refund_notification
(
self
):
""" Verify the support team is notified of the refund request. """
with
mock
.
patch
(
'commerce.signals.create_zendesk_ticket'
)
as
mock_zendesk
:
refund_ids
=
[
1
,
2
,
3
]
send_refund_notification
(
self
.
course_enrollment
,
refund_ids
)
body
=
generate_refund_notification_body
(
self
.
student
,
refund_ids
)
mock_zendesk
.
assert_called_with
(
self
.
student
.
profile
.
name
,
self
.
student
.
email
,
"[Refund] User-Requested Refund"
,
body
,
[
'auto_refund'
])
def
_mock_zendesk_api
(
self
,
status
=
201
):
""" Mock Zendesk's ticket creation API. """
httpretty
.
register_uri
(
httpretty
.
POST
,
urljoin
(
ZENDESK_URL
,
'/api/v2/tickets.json'
),
status
=
status
,
body
=
'{}'
,
content_type
=
JSON
)
def
call_create_zendesk_ticket
(
self
,
name
=
u'Test user'
,
email
=
u'user@example.com'
,
subject
=
u'Test Ticket'
,
body
=
u'I want a refund!'
,
tags
=
None
):
""" Call the create_zendesk_ticket function. """
tags
=
tags
or
[
u'auto_refund'
]
create_zendesk_ticket
(
name
,
email
,
subject
,
body
,
tags
)
@override_settings
(
ZENDESK_URL
=
ZENDESK_URL
,
ZENDESK_USER
=
None
,
ZENDESK_API_KEY
=
None
)
def
test_create_zendesk_ticket_no_settings
(
self
):
""" Verify the Zendesk API is not called if the settings are not all set. """
with
mock
.
patch
(
'requests.post'
)
as
mock_post
:
self
.
call_create_zendesk_ticket
()
self
.
assertFalse
(
mock_post
.
called
)
def
test_create_zendesk_ticket_request_error
(
self
):
"""
Ensure the email sender, recipient, subject, content type, and content
are all correct.
Verify exceptions are handled appropriately if the request to the Zendesk API fails.
We simply need to ensure the exception is not raised beyond the function.
"""
# mock_email_class is the email message class/constructor.
# mock_message is the instance returned by the constructor.
# we need to make assertions regarding both.
mock_message
=
mock
.
MagicMock
()
mock_email_class
.
return_value
=
mock_message
with
mock
.
patch
(
'requests.post'
,
side_effect
=
Timeout
)
as
mock_post
:
self
.
call_create_zendesk_ticket
()
self
.
assertTrue
(
mock_post
.
called
)
refund_ids
=
[
1
,
2
,
3
]
send_refund_notification
(
self
.
course_enrollment
,
refund_ids
)
@httpretty.activate
def
test_create_zendesk_ticket
(
self
):
""" Verify the Zendesk API is called. """
self
.
_mock_zendesk_api
()
# check headers and text content
self
.
assertEqual
(
mock_email_class
.
call_args
[
0
],
(
"[Refund] User-Requested Refund"
,
mock
.
ANY
,
self
.
student
.
email
,
[
'payment@example.com'
]),
)
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_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_PUBLIC_URL_ROOT
,
refund_id
)
for
refund_id
in
refund_ids
]:
self
.
assertRegexpMatches
(
html_body
,
exp
)
# make sure we actually SEND the message too.
self
.
assertTrue
(
mock_message
.
send
.
called
)
name
=
u'Test user'
email
=
u'user@example.com'
subject
=
u'Test Ticket'
body
=
u'I want a refund!'
tags
=
[
u'auto_refund'
]
self
.
call_create_zendesk_ticket
(
name
,
email
,
subject
,
body
,
tags
)
last_request
=
httpretty
.
last_request
()
# Verify the headers
expected
=
{
'content-type'
:
JSON
,
'Authorization'
:
'Basic '
+
base64
.
b64encode
(
'{user}/token:{pwd}'
.
format
(
user
=
ZENDESK_USER
,
pwd
=
ZENDESK_API_KEY
))
}
self
.
assertDictContainsSubset
(
expected
,
last_request
.
headers
)
# Verify the content
expected
=
{
u'ticket'
:
{
u'requester'
:
{
u'name'
:
name
,
u'email'
:
email
},
u'subject'
:
subject
,
u'comment'
:
{
u'body'
:
body
},
u'tags'
:
[
u'LMS'
]
+
tags
}
}
self
.
assertDictEqual
(
json
.
loads
(
last_request
.
body
),
expected
)
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment