diff --git a/lms/djangoapps/support/static/support/jsx/logged_in_user.jsx b/lms/djangoapps/support/static/support/jsx/logged_in_user.jsx
index 3e6ab0fa88..f8994005c5 100644
--- a/lms/djangoapps/support/static/support/jsx/logged_in_user.jsx
+++ b/lms/djangoapps/support/static/support/jsx/logged_in_user.jsx
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import FileUpload from './file_upload';
-function LoggedInUser({ userInformation, setErrorState, zendeskApiHost, accessToken, submitForm }) {
+function LoggedInUser({ userInformation, setErrorState, zendeskApiHost, submitForm }) {
let courseElement;
if (userInformation.enrollments) {
courseElement = (
@@ -70,11 +70,12 @@ function LoggedInUser({ userInformation, setErrorState, zendeskApiHost, accessTo
-
@@ -91,8 +92,7 @@ LoggedInUser.propTypes = {
setErrorState: PropTypes.func.isRequired,
submitForm: PropTypes.func.isRequired,
userInformation: PropTypes.arrayOf(PropTypes.object).isRequired,
- zendeskApiHost: PropTypes.string.isRequired,
- accessToken: PropTypes.string.isRequired,
+ zendeskProxyUrl: PropTypes.string.isRequired,
};
export default LoggedInUser;
diff --git a/lms/djangoapps/support/static/support/jsx/single_support_form.jsx b/lms/djangoapps/support/static/support/jsx/single_support_form.jsx
index c4cc3ebd5b..2ca8aa3ea6 100644
--- a/lms/djangoapps/support/static/support/jsx/single_support_form.jsx
+++ b/lms/djangoapps/support/static/support/jsx/single_support_form.jsx
@@ -31,7 +31,7 @@ class RenderForm extends React.Component {
}
submitForm() {
- const url = `${this.props.context.zendeskApiHost}/api/v2/tickets.json`,
+ const url = this.props.context.zendeskProxyUrl,
$userInfo = $('.user-info'),
request = new XMLHttpRequest(),
$course = $('#course'),
@@ -39,14 +39,17 @@ class RenderForm extends React.Component {
subject: $('#subject').val(),
comment: {
body: $('#message').val(),
- uploads: $.map($('.uploaded-files button'), n => n.id),
},
tags: this.props.context.zendeskTags,
};
let course;
- data.requester = $userInfo.data('email');
+ data.requester = {
+ email: $userInfo.data('email'),
+ name: $userInfo.data('username')
+ };
+
course = $course.find(':selected').val();
if (!course) {
course = $course.val();
@@ -59,12 +62,10 @@ class RenderForm extends React.Component {
if (this.validateData(data)) {
request.open('POST', url, true);
- request.setRequestHeader('Authorization', `Bearer ${this.props.context.accessToken}`);
- request.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
+ request.setRequestHeader('Content-type', 'application/json;charset=UTF-8');
+ request.setRequestHeader('X-CSRFToken', $.cookie('csrftoken'));
- request.send(JSON.stringify({
- ticket: data,
- }));
+ request.send(JSON.stringify(data));
request.onreadystatechange = function success() {
if (request.readyState === 4 && request.status === 201) {
@@ -81,16 +82,7 @@ class RenderForm extends React.Component {
}
validateData(data) {
- const errors = [],
- regex = /^([a-zA-Z0-9_.+-])+@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/;
-
- if (!data.requester) {
- errors.push(gettext('Enter a valid email address.'));
- $('#email').closest('.form-group').addClass('has-error');
- } else if (!regex.test(data.requester)) {
- errors.push(gettext('Enter a valid email address.'));
- $('#email').closest('.form-group').addClass('has-error');
- }
+ const errors = [];
if (!data.subject) {
errors.push(gettext('Enter a subject for your support request.'));
$('#subject').closest('.form-group').addClass('has-error');
@@ -124,8 +116,7 @@ class RenderForm extends React.Component {
if (this.props.context.user) {
userElement = ();
diff --git a/lms/djangoapps/support/views/contact_us.py b/lms/djangoapps/support/views/contact_us.py
index 1dede972e9..2776dd5af5 100644
--- a/lms/djangoapps/support/views/contact_us.py
+++ b/lms/djangoapps/support/views/contact_us.py
@@ -18,8 +18,6 @@ class ContactUsView(View):
def get(self, request):
context = {
'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
- 'zendesk_api_host': settings.ZENDESK_URL,
- 'access_token': 'DUMMY_ACCESS_TOKEN', # LEARNER-3450
'custom_fields': settings.ZENDESK_CUSTOM_FIELDS
}
diff --git a/lms/templates/support/contact_us.html b/lms/templates/support/contact_us.html
index 4bfad724da..4972e82585 100644
--- a/lms/templates/support/contact_us.html
+++ b/lms/templates/support/contact_us.html
@@ -33,8 +33,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string, dump_js_escaped_j
'loginQuery': "${login_query() | n, js_escaped_string}",
'dashboardUrl': "${reverse('dashboard') | n, js_escaped_string}",
'homepageUrl': "${marketing_link('ROOT') | n, js_escaped_string}",
- 'zendeskApiHost': "${zendesk_api_host | n, js_escaped_string}",
- 'accessToken': "${access_token | n, js_escaped_string}",
+ 'zendeskProxyUrl': "${reverse('zendesk_proxy_v1') | n, js_escaped_string}",
'customFields': ${custom_fields | n, dump_js_escaped_json},
'zendeskTags': ${zendesk_tags | n, dump_js_escaped_json},
}
diff --git a/openedx/core/djangoapps/zendesk_proxy/tests/test_utils.py b/openedx/core/djangoapps/zendesk_proxy/tests/test_utils.py
new file mode 100644
index 0000000000..b02e5685f9
--- /dev/null
+++ b/openedx/core/djangoapps/zendesk_proxy/tests/test_utils.py
@@ -0,0 +1,58 @@
+import ddt
+from django.test.utils import override_settings
+from mock import MagicMock, patch
+
+from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
+from openedx.core.lib.api.test_utils import ApiTestCase
+
+
+@ddt.ddt
+@override_settings(
+ ZENDESK_URL="https://www.superrealurlsthataredefinitelynotfake.com",
+ ZENDESK_OAUTH_ACCESS_TOKEN="abcdefghijklmnopqrstuvwxyz1234567890"
+)
+class TestUtils(ApiTestCase):
+ def setUp(self):
+ self.request_data = {
+ 'email': 'JohnQStudent@example.com',
+ 'name': 'John Q. Student',
+ 'subject': 'Python Unit Test Help Request',
+ 'body': "Help! I'm trapped in a unit test factory and I can't get out!",
+ }
+ return super(TestUtils, self).setUp()
+
+ @override_settings(
+ ZENDESK_URL=None,
+ ZENDESK_OAUTH_ACCESS_TOKEN=None
+ )
+ def test_missing_settings(self):
+ status_code = create_zendesk_ticket(
+ requester_name=self.request_data['name'],
+ requester_email=self.request_data['email'],
+ subject=self.request_data['subject'],
+ body=self.request_data['body'],
+ )
+
+ self.assertEqual(status_code, 503)
+
+ @ddt.data(201, 400, 401, 403, 404, 500)
+ def test_zendesk_status_codes(self, mock_code):
+ with patch('requests.post', return_value=MagicMock(status_code=mock_code)):
+ status_code = create_zendesk_ticket(
+ requester_name=self.request_data['name'],
+ requester_email=self.request_data['email'],
+ subject=self.request_data['subject'],
+ body=self.request_data['body'],
+ )
+
+ self.assertEqual(status_code, mock_code)
+
+ def test_unexpected_error_pinging_zendesk(self):
+ with patch('requests.post', side_effect=Exception("WHAMMY")):
+ status_code = create_zendesk_ticket(
+ requester_name=self.request_data['name'],
+ requester_email=self.request_data['email'],
+ subject=self.request_data['subject'],
+ body=self.request_data['body'],
+ )
+ self.assertEqual(status_code, 500)
diff --git a/openedx/core/djangoapps/zendesk_proxy/tests/test_views.py b/openedx/core/djangoapps/zendesk_proxy/tests/test_v0_views.py
similarity index 68%
rename from openedx/core/djangoapps/zendesk_proxy/tests/test_views.py
rename to openedx/core/djangoapps/zendesk_proxy/tests/test_v0_views.py
index 9e85121d36..817807999e 100644
--- a/openedx/core/djangoapps/zendesk_proxy/tests/test_views.py
+++ b/openedx/core/djangoapps/zendesk_proxy/tests/test_v0_views.py
@@ -1,14 +1,14 @@
"""Tests for zendesk_proxy views."""
-from copy import deepcopy
-import ddt
import json
-from mock import MagicMock, patch
+from copy import deepcopy
+import ddt
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
+from mock import MagicMock, patch
-from openedx.core.lib.api.test_utils import ApiTestCase
from openedx.core.djangoapps.zendesk_proxy.v0.views import ZENDESK_REQUESTS_PER_HOUR
+from openedx.core.lib.api.test_utils import ApiTestCase
@ddt.ddt
@@ -50,7 +50,7 @@ class ZendeskProxyTestCase(ApiTestCase):
'content-type': 'application/json',
'Authorization': 'Bearer abcdefghijklmnopqrstuvwxyz1234567890'
},
- 'data': '{"ticket": {"comment": {"body": "Help! I\'m trapped in a unit test factory and I can\'t get out!"}, "subject": "Python Unit Test Help Request", "tags": ["python_unit_test"], "requester": {"name": "John Q. Student", "email": "JohnQStudent@example.com"}}}' # pylint: disable=line-too-long
+ 'data': '{"ticket": {"comment": {"body": "Help! I\'m trapped in a unit test factory and I can\'t get out!", "uploads": null}, "tags": ["python_unit_test"], "subject": "Python Unit Test Help Request", "custom_fields": null, "requester": {"name": "John Q. Student", "email": "JohnQStudent@example.com"}}}' # pylint: disable=line-too-long
}
)
@@ -67,40 +67,6 @@ class ZendeskProxyTestCase(ApiTestCase):
)
self.assertHttpBadRequest(response)
- @override_settings(
- ZENDESK_URL=None,
- ZENDESK_OAUTH_ACCESS_TOKEN=None
- )
- def test_missing_settings(self):
- response = self.request_without_auth(
- 'post',
- self.url,
- data=json.dumps(self.request_data),
- content_type='application/json'
- )
- self.assertEqual(response.status_code, 503)
-
- @ddt.data(201, 400, 401, 403, 404, 500)
- def test_zendesk_status_codes(self, mock_code):
- with patch('requests.post', return_value=MagicMock(status_code=mock_code)):
- response = self.request_without_auth(
- 'post',
- self.url,
- data=json.dumps(self.request_data),
- content_type='application/json'
- )
- self.assertEqual(response.status_code, mock_code)
-
- def test_unexpected_error_pinging_zendesk(self):
- with patch('requests.post', side_effect=Exception("WHAMMY")):
- response = self.request_without_auth(
- 'post',
- self.url,
- data=json.dumps(self.request_data),
- content_type='application/json'
- )
- self.assertEqual(response.status_code, 500)
-
@override_settings(
CACHES={
'default': {
diff --git a/openedx/core/djangoapps/zendesk_proxy/tests/test_v1_views.py b/openedx/core/djangoapps/zendesk_proxy/tests/test_v1_views.py
new file mode 100644
index 0000000000..6f4a17370b
--- /dev/null
+++ b/openedx/core/djangoapps/zendesk_proxy/tests/test_v1_views.py
@@ -0,0 +1,95 @@
+"""Tests for zendesk_proxy views."""
+import json
+from copy import deepcopy
+
+import ddt
+from django.core.urlresolvers import reverse
+from django.test.utils import override_settings
+from mock import MagicMock, patch
+
+from openedx.core.djangoapps.zendesk_proxy.v1.views import ZendeskProxyThrottle
+from openedx.core.lib.api.test_utils import ApiTestCase
+
+
+@ddt.ddt
+@override_settings(
+ ZENDESK_URL="https://www.superrealurlsthataredefinitelynotfake.com",
+ ZENDESK_OAUTH_ACCESS_TOKEN="abcdefghijklmnopqrstuvwxyz1234567890"
+)
+class ZendeskProxyTestCase(ApiTestCase):
+ """Tests for zendesk_proxy views."""
+
+ def setUp(self):
+ self.url = reverse('zendesk_proxy_v1')
+ self.request_data = {
+ 'requester': {
+ 'email': 'JohnQStudent@example.com',
+ 'name': 'John Q. Student'
+ },
+ 'subject': 'Python Unit Test Help Request',
+ 'comment': {
+ 'body': "Help! I'm trapped in a unit test factory and I can't get out!",
+ },
+ 'tags': ['python_unit_test'],
+ 'custom_fields': [
+ {
+ 'id': '001',
+ 'value': 'demo-course'
+ }
+ ],
+ }
+ return super(ZendeskProxyTestCase, self).setUp()
+
+ def test_post(self):
+ with patch('requests.post', return_value=MagicMock(status_code=201)) as mock_post:
+ response = self.request_without_auth(
+ 'post',
+ self.url,
+ data=json.dumps(self.request_data),
+ content_type='application/json'
+ )
+ self.assertHttpCreated(response)
+ (mock_args, mock_kwargs) = mock_post.call_args
+ self.assertEqual(mock_args, ('https://www.superrealurlsthataredefinitelynotfake.com/api/v2/tickets.json',))
+ self.assertEqual(
+ mock_kwargs,
+ {
+ 'headers': {
+ 'content-type': 'application/json',
+ 'Authorization': 'Bearer abcdefghijklmnopqrstuvwxyz1234567890'
+ },
+ 'data': '{"ticket": {"comment": {"body": "Help! I\'m trapped in a unit test factory and I can\'t get out!", "uploads": null}, "tags": ["python_unit_test"], "subject": "Python Unit Test Help Request", "custom_fields": [{"id": "001", "value": "demo-course"}], "requester": {"name": "John Q. Student", "email": "JohnQStudent@example.com"}}}' # pylint: disable=line-too-long
+ }
+ )
+
+ @ddt.data('requester', 'tags')
+ def test_bad_request(self, key_to_delete):
+ test_data = deepcopy(self.request_data)
+ _ = test_data.pop(key_to_delete)
+
+ response = self.request_without_auth(
+ 'post',
+ self.url,
+ data=json.dumps(test_data),
+ content_type='application/json'
+ )
+ self.assertHttpBadRequest(response)
+
+ @override_settings(
+ CACHES={
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'LOCATION': 'zendesk_proxy',
+ }
+ }
+ )
+ def test_rate_limiting(self):
+ """
+ Confirm rate limits work as expected. Note that drf's rate limiting makes use of the default cache to enforce
+ limits; that's why this test needs a "real" default cache (as opposed to the usual-for-tests DummyCache)
+ """
+
+ for _ in range(ZendeskProxyThrottle().num_requests):
+ self.request_without_auth('post', self.url)
+ response = self.request_without_auth('post', self.url)
+ self.assertEqual(response.status_code, 429)
diff --git a/openedx/core/djangoapps/zendesk_proxy/urls.py b/openedx/core/djangoapps/zendesk_proxy/urls.py
index 668f5f6d9c..161bbf924c 100644
--- a/openedx/core/djangoapps/zendesk_proxy/urls.py
+++ b/openedx/core/djangoapps/zendesk_proxy/urls.py
@@ -5,7 +5,9 @@ Map urls to the relevant view handlers
from django.conf.urls import url
from openedx.core.djangoapps.zendesk_proxy.v0.views import ZendeskPassthroughView as v0_view
+from openedx.core.djangoapps.zendesk_proxy.v1.views import ZendeskPassthroughView as v1_view
urlpatterns = [
url(r'^v0$', v0_view.as_view(), name='zendesk_proxy_v0'),
+ url(r'^v1$', v1_view.as_view(), name='zendesk_proxy_v1'),
]
diff --git a/openedx/core/djangoapps/zendesk_proxy/utils.py b/openedx/core/djangoapps/zendesk_proxy/utils.py
index 4230031cbe..061a269633 100644
--- a/openedx/core/djangoapps/zendesk_proxy/utils.py
+++ b/openedx/core/djangoapps/zendesk_proxy/utils.py
@@ -12,7 +12,7 @@ from rest_framework import status
log = logging.getLogger(__name__)
-def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=None):
+def create_zendesk_ticket(requester_name, requester_email, subject, body, custom_fields=None, uploads=None, tags=None):
"""
Create a Zendesk ticket via API.
@@ -24,8 +24,9 @@ def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=N
"""Internal helper to standardize error message. This allows for simpler splunk alerts."""
return 'zendesk_proxy action required\n{}\nNo ticket created for payload {}'.format(details, payload)
- # Remove duplicates from tags list
- tags = list(set(tags))
+ if tags:
+ # Remove duplicates from tags list
+ tags = list(set(tags))
data = {
'ticket': {
@@ -34,7 +35,11 @@ def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=N
'email': requester_email
},
'subject': subject,
- 'comment': {'body': body},
+ 'comment': {
+ 'body': body,
+ 'uploads': uploads
+ },
+ 'custom_fields': custom_fields,
'tags': tags
}
}
diff --git a/openedx/core/djangoapps/zendesk_proxy/v1/__init__.py b/openedx/core/djangoapps/zendesk_proxy/v1/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/zendesk_proxy/v1/views.py b/openedx/core/djangoapps/zendesk_proxy/v1/views.py
new file mode 100644
index 0000000000..4cf346e9f2
--- /dev/null
+++ b/openedx/core/djangoapps/zendesk_proxy/v1/views.py
@@ -0,0 +1,69 @@
+"""
+Define request handlers used by the zendesk_proxy djangoapp
+"""
+from rest_framework import status
+from rest_framework.parsers import JSONParser
+from rest_framework.response import Response
+from rest_framework.throttling import UserRateThrottle
+from rest_framework.views import APIView
+
+from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
+
+REQUESTS_PER_HOUR = 50
+
+
+class ZendeskProxyThrottle(UserRateThrottle):
+ """
+ Custom throttle rates for this particular endpoint's use case.
+ """
+
+ def __init__(self):
+ self.rate = '{}/hour'.format(REQUESTS_PER_HOUR)
+ super(ZendeskProxyThrottle, self).__init__()
+
+
+class ZendeskPassthroughView(APIView):
+ """
+ An APIView that will take in inputs from an unauthenticated endpoint, and use them to securely create a zendesk
+ ticket.
+ """
+ throttle_classes = ZendeskProxyThrottle,
+ parser_classes = JSONParser,
+
+ def post(self, request):
+ """
+ request body is expected to look like this:
+ {
+ "requester": {
+ "email": "john@example.com",
+ "name": "name"
+ },
+ "subject": "test subject",
+ "comment": {
+ "body": "message details",
+ "uploads": ['file_token'],
+ },
+ "custom_fields": [
+ {
+ "id": '001',
+ "value": 'demo-course'
+ }
+ ],
+ "tags": ["LMS"]
+ }
+ """
+ try:
+ proxy_status = create_zendesk_ticket(
+ requester_name=request.data['requester']['name'],
+ requester_email=request.data['requester']['email'],
+ subject=request.data['subject'],
+ body=request.data['comment']['body'],
+ custom_fields=request.data['custom_fields'],
+ tags=request.data['tags']
+ )
+ except KeyError:
+ return Response(status=status.HTTP_400_BAD_REQUEST)
+
+ return Response(
+ status=proxy_status
+ )