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
- + {/*TODO file uploading will be done after initial release*/} + {/**/}
@@ -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 + )