617
common/djangoapps/util/tests/test_submit_feedback.py
Normal file
617
common/djangoapps/util/tests/test_submit_feedback.py
Normal file
@@ -0,0 +1,617 @@
|
||||
"""Tests for the Zendesk"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import json
|
||||
from smtplib import SMTPException
|
||||
|
||||
import httpretty
|
||||
import mock
|
||||
from ddt import data, ddt, unpack
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import Http404
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from zendesk import ZendeskError
|
||||
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseServiceMockMixin
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from student.tests.test_configuration_overrides import fake_get_value
|
||||
from util import views
|
||||
|
||||
TEST_SUPPORT_EMAIL = "support@example.com"
|
||||
TEST_ZENDESK_CUSTOM_FIELD_CONFIG = {
|
||||
"course_id": 1234,
|
||||
"enrollment_mode": 5678,
|
||||
'enterprise_customer_name': 'enterprise_customer_name'
|
||||
}
|
||||
TEST_REQUEST_HEADERS = {
|
||||
"HTTP_REFERER": "test_referer",
|
||||
"HTTP_USER_AGENT": "test_user_agent",
|
||||
"REMOTE_ADDR": "1.2.3.4",
|
||||
"SERVER_NAME": "test_server",
|
||||
}
|
||||
|
||||
|
||||
def fake_support_backend_values(name, default=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Method for getting configuration override values for support email.
|
||||
"""
|
||||
config_dict = {
|
||||
"CONTACT_FORM_SUBMISSION_BACKEND": "email",
|
||||
"email_from_address": TEST_SUPPORT_EMAIL,
|
||||
}
|
||||
return config_dict[name]
|
||||
|
||||
|
||||
@ddt
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True})
|
||||
@override_settings(
|
||||
DEFAULT_FROM_EMAIL=TEST_SUPPORT_EMAIL,
|
||||
ZENDESK_URL="dummy",
|
||||
ZENDESK_USER="dummy",
|
||||
ZENDESK_API_KEY="dummy",
|
||||
ZENDESK_CUSTOM_FIELDS={},
|
||||
)
|
||||
@mock.patch("util.views._ZendeskApi", autospec=True)
|
||||
class SubmitFeedbackTest(EnterpriseServiceMockMixin, TestCase):
|
||||
"""
|
||||
Class to test the submit_feedback function in views.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""Set up data for the test case"""
|
||||
super(SubmitFeedbackTest, self).setUp()
|
||||
self._request_factory = RequestFactory()
|
||||
self._anon_user = AnonymousUser()
|
||||
self._auth_user = UserFactory.create(
|
||||
email="test@edx.org",
|
||||
username="test",
|
||||
profile__name="Test User"
|
||||
)
|
||||
self._anon_fields = {
|
||||
"email": "test@edx.org",
|
||||
"name": "Test User",
|
||||
"subject": "a subject",
|
||||
"details": "some details",
|
||||
"issue_type": "test_issue"
|
||||
}
|
||||
# This does not contain issue_type nor course_id to ensure that they are optional
|
||||
self._auth_fields = {"subject": "a subject", "details": "some details"}
|
||||
|
||||
# Create a service user, because the track selection page depends on it
|
||||
UserFactory.create(
|
||||
username='enterprise_worker',
|
||||
email="enterprise_worker@example.com",
|
||||
password="edx",
|
||||
)
|
||||
|
||||
def _build_and_run_request(self, user, fields):
|
||||
"""
|
||||
Generate a request and invoke the view, returning the response.
|
||||
|
||||
The request will be a POST request from the given `user`, with the given
|
||||
`fields` in the POST body.
|
||||
"""
|
||||
req = self._request_factory.post(
|
||||
"/submit_feedback",
|
||||
data=fields,
|
||||
HTTP_REFERER=TEST_REQUEST_HEADERS["HTTP_REFERER"],
|
||||
HTTP_USER_AGENT=TEST_REQUEST_HEADERS["HTTP_USER_AGENT"],
|
||||
REMOTE_ADDR=TEST_REQUEST_HEADERS["REMOTE_ADDR"],
|
||||
SERVER_NAME=TEST_REQUEST_HEADERS["SERVER_NAME"],
|
||||
)
|
||||
req.site = SiteFactory.create()
|
||||
req.user = user
|
||||
return views.submit_feedback(req)
|
||||
|
||||
def _assert_bad_request(self, response, field, zendesk_mock_class):
|
||||
"""
|
||||
Assert that the given `response` contains correct failure data.
|
||||
|
||||
It should have a 400 status code, and its content should be a JSON
|
||||
object containing the specified `field` and an `error`.
|
||||
"""
|
||||
self.assertEqual(response.status_code, 400)
|
||||
resp_json = json.loads(response.content)
|
||||
self.assertIn("field", resp_json)
|
||||
self.assertEqual(resp_json["field"], field)
|
||||
self.assertIn("error", resp_json)
|
||||
# There should be absolutely no interaction with Zendesk
|
||||
self.assertFalse(zendesk_mock_class.return_value.mock_calls)
|
||||
|
||||
def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class):
|
||||
"""
|
||||
Invoke the view with a request missing a field and assert correctness.
|
||||
|
||||
The request will be a POST request from the given `user`, with POST
|
||||
fields taken from `fields` minus the entry specified by `omit_field`.
|
||||
The response should have a 400 (bad request) status code and specify
|
||||
the invalid field and an error message, and the Zendesk API should not
|
||||
have been invoked.
|
||||
"""
|
||||
filtered_fields = {k: v for (k, v) in fields.items() if k != omit_field}
|
||||
resp = self._build_and_run_request(user, filtered_fields)
|
||||
self._assert_bad_request(resp, omit_field, zendesk_mock_class)
|
||||
|
||||
def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class):
|
||||
"""
|
||||
Invoke the view with an empty field and assert correctness.
|
||||
|
||||
The request will be a POST request from the given `user`, with POST
|
||||
fields taken from `fields`, replacing the entry specified by
|
||||
`empty_field` with the empty string. The response should have a 400
|
||||
(bad request) status code and specify the invalid field and an error
|
||||
message, and the Zendesk API should not have been invoked.
|
||||
"""
|
||||
altered_fields = fields.copy()
|
||||
altered_fields[empty_field] = ""
|
||||
resp = self._build_and_run_request(user, altered_fields)
|
||||
self._assert_bad_request(resp, empty_field, zendesk_mock_class)
|
||||
|
||||
def _test_success(self, user, fields):
|
||||
"""
|
||||
Generate a request, invoke the view, and assert success.
|
||||
|
||||
The request will be a POST request from the given `user`, with the given
|
||||
`fields` in the POST body. The response should have a 200 (success)
|
||||
status code.
|
||||
"""
|
||||
resp = self._build_and_run_request(user, fields)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def _build_zendesk_ticket(self, recipient, name, email, subject, details, tags, custom_fields=None):
|
||||
"""
|
||||
Build a Zendesk ticket that can be used in assertions to verify that the correct
|
||||
data was submitted to create a Zendesk ticket.
|
||||
"""
|
||||
ticket = {
|
||||
"ticket": {
|
||||
"recipient": recipient,
|
||||
"requester": {"name": name, "email": email},
|
||||
"subject": subject,
|
||||
"comment": {"body": details},
|
||||
"tags": tags
|
||||
}
|
||||
}
|
||||
|
||||
if custom_fields is not None:
|
||||
ticket["ticket"]["custom_fields"] = custom_fields
|
||||
|
||||
return ticket
|
||||
|
||||
def _build_zendesk_ticket_update(self, request_headers, username=None):
|
||||
"""
|
||||
Build a Zendesk ticket update that can be used in assertions to verify that the correct
|
||||
data was submitted to update a Zendesk ticket.
|
||||
"""
|
||||
body = []
|
||||
if username:
|
||||
body.append("username: {}".format(username))
|
||||
|
||||
# FIXME the tests rely on the body string being built in this specific order, which doesn't seem
|
||||
# reliable given that the view builds the string by iterating over a dictionary.
|
||||
header_text_mapping = [
|
||||
("Client IP", "REMOTE_ADDR"),
|
||||
("Host", "SERVER_NAME"),
|
||||
("Page", "HTTP_REFERER"),
|
||||
("Browser", "HTTP_USER_AGENT")
|
||||
]
|
||||
|
||||
for text, header in header_text_mapping:
|
||||
body.append("{}: {}".format(text, request_headers[header]))
|
||||
|
||||
body = "Additional information:\n\n" + "\n".join(body)
|
||||
return {"ticket": {"comment": {"public": False, "body": body}}}
|
||||
|
||||
def _assert_zendesk_called(self, zendesk_mock, ticket_id, ticket, ticket_update):
|
||||
"""Assert that Zendesk was called with the correct ticket and ticket_update."""
|
||||
expected_zendesk_calls = [mock.call.create_ticket(ticket), mock.call.update_ticket(ticket_id, ticket_update)]
|
||||
self.assertEqual(zendesk_mock.mock_calls, expected_zendesk_calls)
|
||||
|
||||
def test_bad_request_anon_user_no_name(self, zendesk_mock_class):
|
||||
"""Test a request from an anonymous user not specifying `name`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class)
|
||||
|
||||
def test_bad_request_anon_user_no_email(self, zendesk_mock_class):
|
||||
"""Test a request from an anonymous user not specifying `email`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class)
|
||||
|
||||
def test_bad_request_anon_user_invalid_email(self, zendesk_mock_class):
|
||||
"""Test a request from an anonymous user specifying an invalid `email`."""
|
||||
fields = self._anon_fields.copy()
|
||||
fields["email"] = "This is not a valid email address!"
|
||||
resp = self._build_and_run_request(self._anon_user, fields)
|
||||
self._assert_bad_request(resp, "email", zendesk_mock_class)
|
||||
|
||||
def test_bad_request_anon_user_no_subject(self, zendesk_mock_class):
|
||||
"""Test a request from an anonymous user not specifying `subject`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class)
|
||||
|
||||
def test_bad_request_anon_user_no_details(self, zendesk_mock_class):
|
||||
"""Test a request from an anonymous user not specifying `details`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class)
|
||||
|
||||
def test_valid_request_anon_user(self, zendesk_mock_class):
|
||||
"""
|
||||
Test a valid request from an anonymous user.
|
||||
|
||||
The response should have a 200 (success) status code, and a ticket with
|
||||
the given information should have been submitted via the Zendesk API.
|
||||
"""
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
user = self._anon_user
|
||||
fields = self._anon_fields
|
||||
|
||||
ticket_id = 42
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
|
||||
ticket = self._build_zendesk_ticket(
|
||||
recipient=TEST_SUPPORT_EMAIL,
|
||||
name=fields["name"],
|
||||
email=fields["email"],
|
||||
subject=fields["subject"],
|
||||
details=fields["details"],
|
||||
tags=[fields["issue_type"], "LMS"]
|
||||
)
|
||||
|
||||
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS)
|
||||
|
||||
self._test_success(user, fields)
|
||||
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
|
||||
|
||||
@mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value)
|
||||
def test_valid_request_anon_user_configuration_override(self, zendesk_mock_class):
|
||||
"""
|
||||
Test a valid request from an anonymous user to a mocked out site with configuration override
|
||||
|
||||
The response should have a 200 (success) status code, and a ticket with
|
||||
the given information should have been submitted via the Zendesk API with the additional
|
||||
tag that will come from site configuration override.
|
||||
"""
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
user = self._anon_user
|
||||
fields = self._anon_fields
|
||||
|
||||
ticket_id = 42
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
|
||||
ticket = self._build_zendesk_ticket(
|
||||
recipient=fake_get_value("email_from_address"),
|
||||
name=fields["name"],
|
||||
email=fields["email"],
|
||||
subject=fields["subject"],
|
||||
details=fields["details"],
|
||||
tags=[fields["issue_type"], "LMS", "site_name_{}".format(fake_get_value("SITE_NAME").replace(".", "_"))]
|
||||
)
|
||||
|
||||
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS)
|
||||
|
||||
self._test_success(user, fields)
|
||||
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
|
||||
|
||||
@data("course-v1:testOrg+testCourseNumber+testCourseRun", "", None)
|
||||
@override_settings(ZENDESK_CUSTOM_FIELDS=TEST_ZENDESK_CUSTOM_FIELD_CONFIG)
|
||||
def test_valid_request_anon_user_with_custom_fields(self, course_id, zendesk_mock_class):
|
||||
"""
|
||||
Test a valid request from an anonymous user when configured to use Zendesk Custom Fields.
|
||||
|
||||
The response should have a 200 (success) status code, and a ticket with
|
||||
the given information should have been submitted via the Zendesk API. When course_id is
|
||||
present, it should be sent to Zendesk via a custom field. When course_id is blank or missing,
|
||||
the request should still be processed successfully.
|
||||
"""
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
user = self._anon_user
|
||||
|
||||
fields = self._anon_fields.copy()
|
||||
if course_id is not None:
|
||||
fields["course_id"] = course_id
|
||||
|
||||
ticket_id = 42
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
|
||||
zendesk_tags = [fields["issue_type"], "LMS"]
|
||||
zendesk_custom_fields = None
|
||||
if course_id:
|
||||
# FIXME the tests rely on the tags being in this specific order, which doesn't seem
|
||||
# reliable given that the view builds the list by iterating over a dictionary.
|
||||
zendesk_tags.insert(0, course_id)
|
||||
zendesk_custom_fields = [
|
||||
{"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["course_id"], "value": course_id}
|
||||
]
|
||||
|
||||
ticket = self._build_zendesk_ticket(
|
||||
recipient=TEST_SUPPORT_EMAIL,
|
||||
name=fields["name"],
|
||||
email=fields["email"],
|
||||
subject=fields["subject"],
|
||||
details=fields["details"],
|
||||
tags=zendesk_tags,
|
||||
custom_fields=zendesk_custom_fields
|
||||
)
|
||||
|
||||
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS)
|
||||
|
||||
self._test_success(user, fields)
|
||||
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
|
||||
|
||||
def test_bad_request_auth_user_no_subject(self, zendesk_mock_class):
|
||||
"""Test a request from an authenticated user not specifying `subject`."""
|
||||
self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class)
|
||||
|
||||
def test_bad_request_auth_user_no_details(self, zendesk_mock_class):
|
||||
"""Test a request from an authenticated user not specifying `details`."""
|
||||
self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class)
|
||||
|
||||
def test_valid_request_auth_user(self, zendesk_mock_class):
|
||||
"""
|
||||
Test a valid request from an authenticated user.
|
||||
|
||||
The response should have a 200 (success) status code, and a ticket with
|
||||
the given information should have been submitted via the Zendesk API.
|
||||
"""
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
user = self._auth_user
|
||||
fields = self._auth_fields
|
||||
|
||||
ticket_id = 42
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
|
||||
ticket = self._build_zendesk_ticket(
|
||||
recipient=TEST_SUPPORT_EMAIL,
|
||||
name=user.profile.name,
|
||||
email=user.email,
|
||||
subject=fields["subject"],
|
||||
details=fields["details"],
|
||||
tags=["LMS"]
|
||||
)
|
||||
|
||||
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS, user.username)
|
||||
|
||||
self._test_success(user, fields)
|
||||
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
|
||||
|
||||
@data(
|
||||
("course-v1:testOrg+testCourseNumber+testCourseRun", True),
|
||||
("course-v1:testOrg+testCourseNumber+testCourseRun", False),
|
||||
("", None),
|
||||
(None, None)
|
||||
)
|
||||
@unpack
|
||||
@override_settings(ZENDESK_CUSTOM_FIELDS=TEST_ZENDESK_CUSTOM_FIELD_CONFIG)
|
||||
def test_valid_request_auth_user_with_custom_fields(self, course_id, enrolled, zendesk_mock_class):
|
||||
"""
|
||||
Test a valid request from an authenticated user when configured to use Zendesk Custom Fields.
|
||||
|
||||
The response should have a 200 (success) status code, and a ticket with
|
||||
the given information should have been submitted via the Zendesk API. When course_id is
|
||||
present, it should be sent to Zendesk via a custom field, along with the enrollment mode
|
||||
if the user has an active enrollment for that course. When course_id is blank or missing,
|
||||
the request should still be processed successfully.
|
||||
"""
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
user = self._auth_user
|
||||
|
||||
fields = self._auth_fields.copy()
|
||||
if course_id is not None:
|
||||
fields["course_id"] = course_id
|
||||
|
||||
ticket_id = 42
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
|
||||
zendesk_tags = ["LMS"]
|
||||
zendesk_custom_fields = None
|
||||
if course_id:
|
||||
# FIXME the tests rely on the tags being in this specific order, which doesn't seem
|
||||
# reliable given that the view builds the list by iterating over a dictionary.
|
||||
zendesk_tags.insert(0, course_id)
|
||||
zendesk_custom_fields = [
|
||||
{"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["course_id"], "value": course_id}
|
||||
]
|
||||
if enrolled is not None:
|
||||
enrollment = CourseEnrollmentFactory.create(
|
||||
user=user,
|
||||
course_id=course_id,
|
||||
is_active=enrolled
|
||||
)
|
||||
if enrollment.is_active:
|
||||
zendesk_custom_fields.append(
|
||||
{"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["enrollment_mode"], "value": enrollment.mode}
|
||||
)
|
||||
|
||||
ticket = self._build_zendesk_ticket(
|
||||
recipient=TEST_SUPPORT_EMAIL,
|
||||
name=user.profile.name,
|
||||
email=user.email,
|
||||
subject=fields["subject"],
|
||||
details=fields["details"],
|
||||
tags=zendesk_tags,
|
||||
custom_fields=zendesk_custom_fields
|
||||
)
|
||||
|
||||
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS, user.username)
|
||||
|
||||
self._test_success(user, fields)
|
||||
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
|
||||
|
||||
@httpretty.activate
|
||||
@data(
|
||||
("course-v1:testOrg+testCourseNumber+testCourseRun", True),
|
||||
("course-v1:testOrg+testCourseNumber+testCourseRun", False),
|
||||
)
|
||||
@unpack
|
||||
@override_settings(ZENDESK_CUSTOM_FIELDS=TEST_ZENDESK_CUSTOM_FIELD_CONFIG)
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=True))
|
||||
def test_valid_request_auth_user_with_enterprise_info(self, course_id, enrolled, zendesk_mock_class):
|
||||
"""
|
||||
Test a valid request from an authenticated user with enterprise tags.
|
||||
"""
|
||||
self.mock_enterprise_learner_api()
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
user = self._auth_user
|
||||
|
||||
fields = self._auth_fields.copy()
|
||||
if course_id is not None:
|
||||
fields["course_id"] = course_id
|
||||
|
||||
ticket_id = 42
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
|
||||
zendesk_tags = ["enterprise_learner", "LMS"]
|
||||
zendesk_custom_fields = []
|
||||
|
||||
if course_id:
|
||||
zendesk_tags.insert(0, course_id)
|
||||
zendesk_custom_fields.append({"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["course_id"], "value": course_id})
|
||||
if enrolled is not None:
|
||||
enrollment = CourseEnrollmentFactory.create(
|
||||
user=user,
|
||||
course_id=course_id,
|
||||
is_active=enrolled
|
||||
)
|
||||
if enrollment.is_active:
|
||||
zendesk_custom_fields.append(
|
||||
{"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["enrollment_mode"], "value": enrollment.mode}
|
||||
)
|
||||
|
||||
zendesk_custom_fields.append(
|
||||
{
|
||||
"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["enterprise_customer_name"],
|
||||
"value": 'TestShib'
|
||||
}
|
||||
)
|
||||
|
||||
ticket = self._build_zendesk_ticket(
|
||||
recipient=TEST_SUPPORT_EMAIL,
|
||||
name=user.profile.name,
|
||||
email=user.email,
|
||||
subject=fields["subject"],
|
||||
details=fields["details"],
|
||||
tags=zendesk_tags,
|
||||
custom_fields=zendesk_custom_fields
|
||||
)
|
||||
|
||||
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS, user.username)
|
||||
self._test_success(user, fields)
|
||||
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
|
||||
|
||||
@httpretty.activate
|
||||
@override_settings(ZENDESK_CUSTOM_FIELDS=TEST_ZENDESK_CUSTOM_FIELD_CONFIG)
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=True))
|
||||
def test_request_with_anonymous_user_without_enterprise_info(self, zendesk_mock_class):
|
||||
"""
|
||||
Test tags related to enterprise should not be there in case an unauthenticated user.
|
||||
"""
|
||||
ticket_id = 42
|
||||
self.mock_enterprise_learner_api()
|
||||
user = self._anon_user
|
||||
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
resp = self._build_and_run_request(user, self._anon_fields)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@httpretty.activate
|
||||
@override_settings(ZENDESK_CUSTOM_FIELDS=TEST_ZENDESK_CUSTOM_FIELD_CONFIG)
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=True))
|
||||
def test_tags_in_request_with_auth_user_with_enterprise_info(self, zendesk_mock_class):
|
||||
"""
|
||||
Test tags related to enterprise should be there in case the request is generated by an authenticated user.
|
||||
"""
|
||||
ticket_id = 42
|
||||
self.mock_enterprise_learner_api()
|
||||
user = self._auth_user
|
||||
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.create_ticket.return_value = ticket_id
|
||||
|
||||
resp = self._build_and_run_request(user, self._auth_fields)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_get_request(self, zendesk_mock_class):
|
||||
"""Test that a GET results in a 405 even with all required fields"""
|
||||
req = self._request_factory.get("/submit_feedback", data=self._anon_fields)
|
||||
req.user = self._anon_user
|
||||
resp = views.submit_feedback(req)
|
||||
self.assertEqual(resp.status_code, 405)
|
||||
self.assertIn("Allow", resp)
|
||||
self.assertEqual(resp["Allow"], "POST")
|
||||
# There should be absolutely no interaction with Zendesk
|
||||
self.assertFalse(zendesk_mock_class.mock_calls)
|
||||
|
||||
def test_zendesk_error_on_create(self, zendesk_mock_class):
|
||||
"""
|
||||
Test Zendesk returning an error on ticket creation.
|
||||
|
||||
We should return a 500 error with no body
|
||||
"""
|
||||
err = ZendeskError(msg="", error_code=404)
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.create_ticket.side_effect = err
|
||||
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
self.assertFalse(resp.content)
|
||||
|
||||
def test_zendesk_error_on_update(self, zendesk_mock_class):
|
||||
"""
|
||||
Test for Zendesk returning an error on ticket update.
|
||||
|
||||
If Zendesk returns any error on ticket update, we return a 200 to the
|
||||
browser because the update contains additional information that is not
|
||||
necessary for the user to have submitted their feedback.
|
||||
"""
|
||||
err = ZendeskError(msg="", error_code=500)
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.update_ticket.side_effect = err
|
||||
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": False})
|
||||
def test_not_enabled(self, zendesk_mock_class):
|
||||
"""
|
||||
Test for Zendesk submission not enabled in `settings`.
|
||||
|
||||
We should raise Http404.
|
||||
"""
|
||||
with self.assertRaises(Http404):
|
||||
self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
|
||||
def test_zendesk_not_configured(self, zendesk_mock_class):
|
||||
"""
|
||||
Test for Zendesk not fully configured in `settings`.
|
||||
|
||||
For each required configuration parameter, test that setting it to
|
||||
`None` causes an otherwise valid request to return a 500 error.
|
||||
"""
|
||||
def test_case(missing_config):
|
||||
with mock.patch(missing_config, None):
|
||||
with self.assertRaises(Exception):
|
||||
self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
|
||||
test_case("django.conf.settings.ZENDESK_URL")
|
||||
test_case("django.conf.settings.ZENDESK_USER")
|
||||
test_case("django.conf.settings.ZENDESK_API_KEY")
|
||||
|
||||
@mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_support_backend_values)
|
||||
def test_valid_request_over_email(self, zendesk_mock_class): # pylint: disable=unused-argument
|
||||
with mock.patch("util.views.send_mail") as patched_send_email:
|
||||
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
self.assertEqual(patched_send_email.call_count, 1)
|
||||
self.assertIn(self._anon_fields["email"], str(patched_send_email.call_args))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_support_backend_values)
|
||||
def test_exception_request_over_email(self, zendesk_mock_class): # pylint: disable=unused-argument
|
||||
with mock.patch("util.views.send_mail", side_effect=SMTPException) as patched_send_email:
|
||||
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
self.assertEqual(patched_send_email.call_count, 1)
|
||||
self.assertIn(self._anon_fields["email"], str(patched_send_email.call_args))
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
@@ -4,12 +4,17 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
from functools import wraps
|
||||
from smtplib import SMTPException
|
||||
|
||||
import calc
|
||||
import crum
|
||||
import zendesk
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseServerError
|
||||
from django.core.cache import caches
|
||||
from django.core.mail import send_mail
|
||||
from django.core.validators import ValidationError, validate_email
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseNotAllowed, HttpResponseServerError
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from django.views.defaults import server_error
|
||||
from opaque_keys import InvalidKeyError
|
||||
@@ -17,11 +22,18 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from six.moves import map
|
||||
|
||||
import track.views
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from edxmako.shortcuts import render_to_response, render_to_string
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.features.enterprise_support import api as enterprise_api
|
||||
from student.models import CourseEnrollment
|
||||
from student.roles import GlobalStaff
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DATADOG_FEEDBACK_METRIC = "lms_feedback_submissions"
|
||||
SUPPORT_BACKEND_ZENDESK = "support_ticket"
|
||||
SUPPORT_BACKEND_EMAIL = "email"
|
||||
|
||||
|
||||
def ensure_valid_course_key(view_func):
|
||||
"""
|
||||
@@ -164,6 +176,325 @@ def calculate(request):
|
||||
return HttpResponse(json.dumps({'result': str(result)}))
|
||||
|
||||
|
||||
class _ZendeskApi(object):
|
||||
|
||||
CACHE_PREFIX = 'ZENDESK_API_CACHE'
|
||||
CACHE_TIMEOUT = 60 * 60
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Instantiate the Zendesk API.
|
||||
|
||||
All of `ZENDESK_URL`, `ZENDESK_USER`, and `ZENDESK_API_KEY` must be set
|
||||
in `django.conf.settings`.
|
||||
"""
|
||||
self._zendesk_instance = zendesk.Zendesk(
|
||||
settings.ZENDESK_URL,
|
||||
settings.ZENDESK_USER,
|
||||
settings.ZENDESK_API_KEY,
|
||||
use_api_token=True,
|
||||
api_version=2,
|
||||
# As of 2012-05-08, Zendesk is using a CA that is not
|
||||
# installed on our servers
|
||||
client_args={"disable_ssl_certificate_validation": True}
|
||||
)
|
||||
|
||||
def create_ticket(self, ticket):
|
||||
"""
|
||||
Create the given `ticket` in Zendesk.
|
||||
|
||||
The ticket should have the format specified by the zendesk package.
|
||||
"""
|
||||
ticket_url = self._zendesk_instance.create_ticket(data=ticket)
|
||||
return zendesk.get_id_from_url(ticket_url)
|
||||
|
||||
def update_ticket(self, ticket_id, update):
|
||||
"""
|
||||
Update the Zendesk ticket with id `ticket_id` using the given `update`.
|
||||
|
||||
The update should have the format specified by the zendesk package.
|
||||
"""
|
||||
self._zendesk_instance.update_ticket(ticket_id=ticket_id, data=update)
|
||||
|
||||
def get_group(self, name):
|
||||
"""
|
||||
Find the Zendesk group named `name`. Groups are cached for
|
||||
CACHE_TIMEOUT seconds.
|
||||
|
||||
If a matching group exists, it is returned as a dictionary
|
||||
with the format specifed by the zendesk package.
|
||||
|
||||
Otherwise, returns None.
|
||||
"""
|
||||
cache = caches['default']
|
||||
cache_key = '{prefix}_group_{name}'.format(prefix=self.CACHE_PREFIX, name=name)
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
groups = self._zendesk_instance.list_groups()['groups']
|
||||
for group in groups:
|
||||
if group['name'] == name:
|
||||
cache.set(cache_key, group, self.CACHE_TIMEOUT)
|
||||
return group
|
||||
return None
|
||||
|
||||
|
||||
def _get_zendesk_custom_field_context(request, **kwargs):
|
||||
"""
|
||||
Construct a dictionary of data that can be stored in Zendesk custom fields.
|
||||
"""
|
||||
context = {}
|
||||
|
||||
course_id = request.POST.get("course_id")
|
||||
if not course_id:
|
||||
return context
|
||||
|
||||
context["course_id"] = course_id
|
||||
if not request.user.is_authenticated:
|
||||
return context
|
||||
|
||||
enrollment = CourseEnrollment.get_enrollment(request.user, CourseKey.from_string(course_id))
|
||||
if enrollment and enrollment.is_active:
|
||||
context["enrollment_mode"] = enrollment.mode
|
||||
|
||||
enterprise_learner_data = kwargs.get('learner_data', None)
|
||||
if enterprise_learner_data:
|
||||
enterprise_customer_name = enterprise_learner_data[0]['enterprise_customer']['name']
|
||||
context["enterprise_customer_name"] = enterprise_customer_name
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def _format_zendesk_custom_fields(context):
|
||||
"""
|
||||
Format the data in `context` for compatibility with the Zendesk API.
|
||||
Ignore any keys that have not been configured in `ZENDESK_CUSTOM_FIELDS`.
|
||||
"""
|
||||
custom_fields = []
|
||||
for key, val, in settings.ZENDESK_CUSTOM_FIELDS.items():
|
||||
if key in context:
|
||||
custom_fields.append({"id": val, "value": context[key]})
|
||||
|
||||
return custom_fields
|
||||
|
||||
|
||||
def _record_feedback_in_zendesk(
|
||||
realname,
|
||||
email,
|
||||
subject,
|
||||
details,
|
||||
tags,
|
||||
additional_info,
|
||||
group_name=None,
|
||||
require_update=False,
|
||||
support_email=None,
|
||||
custom_fields=None
|
||||
):
|
||||
"""
|
||||
Create a new user-requested Zendesk ticket.
|
||||
|
||||
Once created, the ticket will be updated with a private comment containing
|
||||
additional information from the browser and server, such as HTTP headers
|
||||
and user state. Returns a boolean value indicating whether ticket creation
|
||||
was successful, regardless of whether the private comment update succeeded.
|
||||
|
||||
If `group_name` is provided, attaches the ticket to the matching Zendesk group.
|
||||
|
||||
If `require_update` is provided, returns False when the update does not
|
||||
succeed. This allows using the private comment to add necessary information
|
||||
which the user will not see in followup emails from support.
|
||||
|
||||
If `custom_fields` is provided, submits data to those fields in Zendesk.
|
||||
"""
|
||||
zendesk_api = _ZendeskApi()
|
||||
|
||||
additional_info_string = (
|
||||
u"Additional information:\n\n" +
|
||||
u"\n".join(u"%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None)
|
||||
)
|
||||
|
||||
# Tag all issues with LMS to distinguish channel in Zendesk; requested by student support team
|
||||
zendesk_tags = list(tags.values()) + ["LMS"]
|
||||
|
||||
# Per edX support, we would like to be able to route feedback items by site via tagging
|
||||
current_site_name = configuration_helpers.get_value("SITE_NAME")
|
||||
if current_site_name:
|
||||
current_site_name = current_site_name.replace(".", "_")
|
||||
zendesk_tags.append("site_name_{site}".format(site=current_site_name))
|
||||
|
||||
new_ticket = {
|
||||
"ticket": {
|
||||
"requester": {"name": realname, "email": email},
|
||||
"subject": subject,
|
||||
"comment": {"body": details},
|
||||
"tags": zendesk_tags
|
||||
}
|
||||
}
|
||||
|
||||
if custom_fields:
|
||||
new_ticket["ticket"]["custom_fields"] = custom_fields
|
||||
|
||||
group = None
|
||||
if group_name is not None:
|
||||
group = zendesk_api.get_group(group_name)
|
||||
if group is not None:
|
||||
new_ticket['ticket']['group_id'] = group['id']
|
||||
if support_email is not None:
|
||||
# If we do not include the `recipient` key here, Zendesk will default to using its default reply
|
||||
# email address when support agents respond to tickets. By setting the `recipient` key here,
|
||||
# we can ensure that WL site users are responded to via the correct Zendesk support email address.
|
||||
new_ticket['ticket']['recipient'] = support_email
|
||||
try:
|
||||
ticket_id = zendesk_api.create_ticket(new_ticket)
|
||||
if group_name is not None and group is None:
|
||||
# Support uses Zendesk groups to track tickets. In case we
|
||||
# haven't been able to correctly group this ticket, log its ID
|
||||
# so it can be found later.
|
||||
log.warning('Unable to find group named %s for Zendesk ticket with ID %s.', group_name, ticket_id)
|
||||
except zendesk.ZendeskError:
|
||||
log.exception("Error creating Zendesk ticket")
|
||||
return False
|
||||
|
||||
# Additional information is provided as a private update so the information
|
||||
# is not visible to the user.
|
||||
ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}}
|
||||
try:
|
||||
zendesk_api.update_ticket(ticket_id, ticket_update)
|
||||
except zendesk.ZendeskError:
|
||||
log.exception("Error updating Zendesk ticket with ID %s.", ticket_id)
|
||||
# The update is not strictly necessary, so do not indicate
|
||||
# failure to the user unless it has been requested with
|
||||
# `require_update`.
|
||||
if require_update:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_feedback_form_context(request):
|
||||
"""
|
||||
Extract the submitted form fields to be used as a context for
|
||||
feedback submission.
|
||||
"""
|
||||
context = {}
|
||||
|
||||
context["subject"] = request.POST["subject"]
|
||||
context["details"] = request.POST["details"]
|
||||
context["tags"] = dict(
|
||||
[(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if request.POST.get(tag)]
|
||||
)
|
||||
|
||||
context["additional_info"] = {}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
context["realname"] = request.user.profile.name
|
||||
context["email"] = request.user.email
|
||||
context["additional_info"]["username"] = request.user.username
|
||||
else:
|
||||
context["realname"] = request.POST["name"]
|
||||
context["email"] = request.POST["email"]
|
||||
|
||||
for header, pretty in [("HTTP_REFERER", "Page"), ("HTTP_USER_AGENT", "Browser"), ("REMOTE_ADDR", "Client IP"),
|
||||
("SERVER_NAME", "Host")]:
|
||||
context["additional_info"][pretty] = request.META.get(header)
|
||||
|
||||
context["support_email"] = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def submit_feedback(request):
|
||||
"""
|
||||
Create a Zendesk ticket or if not available, send an email with the
|
||||
feedback form fields.
|
||||
|
||||
If feedback submission is not enabled, any request will raise `Http404`.
|
||||
If any configuration parameter (`ZENDESK_URL`, `ZENDESK_USER`, or
|
||||
`ZENDESK_API_KEY`) is missing, any request will raise an `Exception`.
|
||||
The request must be a POST request specifying `subject` and `details`.
|
||||
If the user is not authenticated, the request must also specify `name` and
|
||||
`email`. If the user is authenticated, the `name` and `email` will be
|
||||
populated from the user's information. If any required parameter is
|
||||
missing, a 400 error will be returned indicating which field is missing and
|
||||
providing an error message. If Zendesk ticket creation fails, 500 error
|
||||
will be returned with no body; if ticket creation succeeds, an empty
|
||||
successful response (200) will be returned.
|
||||
"""
|
||||
if not settings.FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False):
|
||||
raise Http404()
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
|
||||
def build_error_response(status_code, field, err_msg):
|
||||
return HttpResponse(json.dumps({"field": field, "error": err_msg}), status=status_code)
|
||||
|
||||
required_fields = ["subject", "details"]
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
required_fields += ["name", "email"]
|
||||
|
||||
required_field_errs = {
|
||||
"subject": "Please provide a subject.",
|
||||
"details": "Please provide details.",
|
||||
"name": "Please provide your name.",
|
||||
"email": "Please provide a valid e-mail.",
|
||||
}
|
||||
for field in required_fields:
|
||||
if field not in request.POST or not request.POST[field]:
|
||||
return build_error_response(400, field, required_field_errs[field])
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
try:
|
||||
validate_email(request.POST["email"])
|
||||
except ValidationError:
|
||||
return build_error_response(400, "email", required_field_errs["email"])
|
||||
|
||||
success = False
|
||||
context = get_feedback_form_context(request)
|
||||
|
||||
# Update the tag info with 'enterprise_learner' if the user belongs to an enterprise customer.
|
||||
enterprise_learner_data = enterprise_api.get_enterprise_learner_data(user=request.user)
|
||||
if enterprise_learner_data:
|
||||
context["tags"]["learner_type"] = "enterprise_learner"
|
||||
|
||||
support_backend = configuration_helpers.get_value('CONTACT_FORM_SUBMISSION_BACKEND', SUPPORT_BACKEND_ZENDESK)
|
||||
|
||||
if support_backend == SUPPORT_BACKEND_EMAIL:
|
||||
try:
|
||||
send_mail(
|
||||
subject=render_to_string('emails/contact_us_feedback_email_subject.txt', context),
|
||||
message=render_to_string('emails/contact_us_feedback_email_body.txt', context),
|
||||
from_email=context["support_email"],
|
||||
recipient_list=[context["support_email"]],
|
||||
fail_silently=False
|
||||
)
|
||||
success = True
|
||||
except SMTPException:
|
||||
log.exception('Error sending feedback to contact_us email address.')
|
||||
success = False
|
||||
|
||||
else:
|
||||
if not settings.ZENDESK_URL or not settings.ZENDESK_USER or not settings.ZENDESK_API_KEY:
|
||||
raise Exception("Zendesk enabled but not configured")
|
||||
|
||||
custom_fields = None
|
||||
if settings.ZENDESK_CUSTOM_FIELDS:
|
||||
custom_field_context = _get_zendesk_custom_field_context(request, learner_data=enterprise_learner_data)
|
||||
custom_fields = _format_zendesk_custom_fields(custom_field_context)
|
||||
|
||||
success = _record_feedback_in_zendesk(
|
||||
context["realname"],
|
||||
context["email"],
|
||||
context["subject"],
|
||||
context["details"],
|
||||
context["tags"],
|
||||
context["additional_info"],
|
||||
support_email=context["support_email"],
|
||||
custom_fields=custom_fields
|
||||
)
|
||||
|
||||
return HttpResponse(status=(200 if success else 500))
|
||||
|
||||
|
||||
def info(request):
|
||||
''' Info page (link from main header) '''
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
@@ -38,7 +38,6 @@ from xblock.fields import Scope, String
|
||||
|
||||
import courseware.views.views as views
|
||||
import shoppingcart
|
||||
|
||||
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
@@ -847,8 +846,8 @@ class ViewsTestCase(ModuleStoreTestCase):
|
||||
url = reverse('submit_financial_assistance_request')
|
||||
return self.client.post(url, json.dumps(data), content_type='application/json')
|
||||
|
||||
@patch.object(views, 'create_zendesk_ticket', return_value=200)
|
||||
def test_submit_financial_assistance_request(self, mock_create_zendesk_ticket):
|
||||
@patch.object(views, '_record_feedback_in_zendesk')
|
||||
def test_submit_financial_assistance_request(self, mock_record_feedback):
|
||||
username = self.user.username
|
||||
course = six.text_type(self.course_key)
|
||||
legal_name = 'Jesse Pinkman'
|
||||
@@ -872,12 +871,10 @@ class ViewsTestCase(ModuleStoreTestCase):
|
||||
response = self._submit_financial_assistance_form(data)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
__, __, ticket_subject, __ = mock_create_zendesk_ticket.call_args[0]
|
||||
mocked_kwargs = mock_create_zendesk_ticket.call_args[1]
|
||||
group_name = mocked_kwargs['group']
|
||||
tags = mocked_kwargs['tags']
|
||||
additional_info = mocked_kwargs['additional_info']
|
||||
|
||||
__, __, ticket_subject, __, tags, additional_info = mock_record_feedback.call_args[0]
|
||||
mocked_kwargs = mock_record_feedback.call_args[1]
|
||||
group_name = mocked_kwargs['group_name']
|
||||
require_update = mocked_kwargs['require_update']
|
||||
private_comment = '\n'.join(list(additional_info.values()))
|
||||
for info in (country, income, reason_for_applying, goals, effort, username, legal_name, course):
|
||||
self.assertIn(info, private_comment)
|
||||
@@ -894,9 +891,10 @@ class ViewsTestCase(ModuleStoreTestCase):
|
||||
self.assertDictContainsSubset({'course_id': course}, tags)
|
||||
self.assertIn('Client IP', additional_info)
|
||||
self.assertEqual(group_name, 'Financial Assistance')
|
||||
self.assertTrue(require_update)
|
||||
|
||||
@patch.object(views, 'create_zendesk_ticket', return_value=500)
|
||||
def test_zendesk_submission_failed(self, _mock_create_zendesk_ticket):
|
||||
@patch.object(views, '_record_feedback_in_zendesk', return_value=False)
|
||||
def test_zendesk_submission_failed(self, _mock_record_feedback):
|
||||
response = self._submit_financial_assistance_form({
|
||||
'username': self.user.username,
|
||||
'course': six.text_type(self.course.id),
|
||||
|
||||
@@ -94,7 +94,6 @@ from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
|
||||
from openedx.features.course_experience import (
|
||||
@@ -113,7 +112,7 @@ from track import segment
|
||||
from util.cache import cache, cache_if_anonymous
|
||||
from util.db import outer_atomic
|
||||
from util.milestones_helpers import get_prerequisite_courses_display
|
||||
from util.views import ensure_valid_course_key, ensure_valid_usage_key
|
||||
from util.views import _record_feedback_in_zendesk, ensure_valid_course_key, ensure_valid_usage_key
|
||||
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
|
||||
@@ -1633,7 +1632,7 @@ def financial_assistance_request(request):
|
||||
# Thrown if fields are missing
|
||||
return HttpResponseBadRequest(u'The field {} is required.'.format(text_type(err)))
|
||||
|
||||
zendesk_submitted = create_zendesk_ticket(
|
||||
zendesk_submitted = _record_feedback_in_zendesk(
|
||||
legal_name,
|
||||
email,
|
||||
u'Financial assistance request for learner {username} in course {course_name}'.format(
|
||||
@@ -1641,12 +1640,12 @@ def financial_assistance_request(request):
|
||||
course_name=course.display_name
|
||||
),
|
||||
u'Financial Assistance Request',
|
||||
tags={'course_id': course_id},
|
||||
{'course_id': course_id},
|
||||
# Send the application as additional info on the ticket so
|
||||
# that it is not shown when support replies. This uses
|
||||
# OrderedDict so that information is presented in the right
|
||||
# order.
|
||||
additional_info=OrderedDict((
|
||||
OrderedDict((
|
||||
('Username', username),
|
||||
('Full Name', legal_name),
|
||||
('Course ID', course_id),
|
||||
@@ -1658,9 +1657,11 @@ def financial_assistance_request(request):
|
||||
(FA_EFFORT_LABEL, '\n' + effort + '\n\n'),
|
||||
('Client IP', ip_address),
|
||||
)),
|
||||
group='Financial Assistance',
|
||||
group_name='Financial Assistance',
|
||||
require_update=True
|
||||
)
|
||||
if not (zendesk_submitted >= 200 and zendesk_submitted < 300):
|
||||
|
||||
if not zendesk_submitted:
|
||||
# The call to Zendesk failed. The frontend will display a
|
||||
# message to the user.
|
||||
return HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -167,6 +167,9 @@ FEATURES = {
|
||||
# Staff Debug tool.
|
||||
'ENABLE_STUDENT_HISTORY_VIEW': True,
|
||||
|
||||
# Provide a UI to allow users to submit feedback from the LMS (left-hand help modal)
|
||||
'ENABLE_FEEDBACK_SUBMISSION': False,
|
||||
|
||||
# Turn on a page that lets staff enter Python code to be run in the
|
||||
# sandbox, for testing whether it's enabled properly.
|
||||
'ENABLE_DEBUG_RUN_PYTHON': False,
|
||||
|
||||
358
lms/templates/help_modal.html
Normal file
358
lms/templates/help_modal.html
Normal file
@@ -0,0 +1,358 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%!
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.urls import reverse
|
||||
from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from xmodule.tabs import CourseTabList
|
||||
%>
|
||||
|
||||
% if settings.FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False):
|
||||
|
||||
<div class="help-tab hidden-mobile">
|
||||
<a href="#help-modal" rel="leanModal" role="button">${_("Support")}</a>
|
||||
</div>
|
||||
|
||||
<div id="help-modal" class="modal" aria-hidden="true" role="dialog" aria-modal="true" tabindex="-1" aria-labelledby="support-platform-name">
|
||||
<div class="inner-wrapper">
|
||||
## TODO: find a way to refactor this
|
||||
<button class="btn-link close-modal" tabindex="0">
|
||||
<span class="icon fa fa-remove" aria-hidden="true"></span>
|
||||
<span class="sr">
|
||||
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
|
||||
${_('Close')}
|
||||
</span>
|
||||
</button>
|
||||
<div id="help_wrapper">
|
||||
|
||||
<header>
|
||||
<h2 id="support-platform-name">
|
||||
${Text(_('{platform_name} Support')).format(
|
||||
platform_name=HTML(u'<span class="edx">{}</span>').format(static.get_platform_name())
|
||||
)}
|
||||
</h2>
|
||||
<hr>
|
||||
</header>
|
||||
|
||||
<%
|
||||
discussion_tab = CourseTabList.get_discussion(course) if course else None
|
||||
discussion_link = discussion_tab.link_func(course, reverse) if (discussion_tab and discussion_tab.is_enabled(course, user=user)) else None
|
||||
%>
|
||||
|
||||
% if discussion_link:
|
||||
<p>${Text(_('For {strong_start}questions on course lectures, homework, tools, or materials for this course{strong_end}, post in the {link_start}course discussion forum{link_end}.')).format(
|
||||
strong_start=HTML('<strong>'),
|
||||
strong_end=HTML('</strong>'),
|
||||
link_start=HTML('<a href="{url}" target="_blank">').format(
|
||||
url=discussion_link
|
||||
),
|
||||
link_end=HTML('</a>'),
|
||||
)}
|
||||
</p>
|
||||
% endif
|
||||
|
||||
<p>${Text(_('Have {strong_start}general questions about {platform_name}{strong_end}? You can find lots of helpful information in the {platform_name} {link_start}FAQ{link_end}.')).format(
|
||||
strong_start=HTML('<strong>'),
|
||||
strong_end=HTML('</strong>'),
|
||||
link_start=HTML('<a href="{url}" id="feedback-faq-link" target="_blank">').format(
|
||||
url=marketing_link('FAQ')
|
||||
),
|
||||
link_end=HTML('</a>'),
|
||||
platform_name=static.get_platform_name())}
|
||||
</p>
|
||||
|
||||
<p>${Text(_('Have a {strong_start}question about something specific{strong_end}? You can contact the {platform_name} general support team directly:')).format(
|
||||
strong_start=HTML('<strong>'),
|
||||
strong_end=HTML('</strong>'),
|
||||
platform_name=static.get_platform_name()
|
||||
)}</p>
|
||||
<hr>
|
||||
|
||||
<div class="help-buttons">
|
||||
<button type="button" class="btn btn-outline-primary" id="feedback_link_problem">${_('Report a problem')}</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="feedback_link_suggestion">${_('Make a suggestion')}</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="feedback_link_question">${_('Ask a question')}</button>
|
||||
</div>
|
||||
|
||||
<p class="note">${_('Please note: The {platform_name} support team is English speaking. While we will do our best to address your inquiry in any language, our responses will be in English.').format(
|
||||
platform_name=static.get_platform_name()
|
||||
)}</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="feedback_form_wrapper">
|
||||
|
||||
<header></header>
|
||||
|
||||
<form id="feedback_form" class="feedback_form" method="post" data-remote="true" action="/submit_feedback">
|
||||
<div class="feedback-form-error" aria-live="polite">
|
||||
<div id="feedback_error" class="modal-form-error" tabindex="-1"></div>
|
||||
</div>
|
||||
% if not user.is_authenticated:
|
||||
<label data-field="name" for="feedback_form_name">${_('Name')}*</label>
|
||||
<input name="name" type="text" id="feedback_form_name" required>
|
||||
<label data-field="email" for="feedback_form_email">${_('E-mail')}*</label>
|
||||
<input name="email" type="text" id="feedback_form_email" required>
|
||||
% endif
|
||||
|
||||
<div class="js-course-id-anchor">
|
||||
% if course:
|
||||
<input name="course_id" type="hidden" value="${unicode(course.id)}">
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<label data-field="subject" for="feedback_form_subject">${_('Briefly describe your issue')}*</label>
|
||||
<input name="subject" type="text" id="feedback_form_subject" required>
|
||||
|
||||
<label data-field="details" for="feedback_form_details">${_('Tell us the details')}*</label>
|
||||
<span class="tip" id="feedback_form_details_tip">${_('Describe what you were doing when you encountered the issue. Include any details that will help us to troubleshoot, including error messages that you saw.')}</span>
|
||||
<textarea name="details" id="feedback_form_details" required aria-describedby="feedback_form_details_tip"></textarea>
|
||||
|
||||
<input name="issue_type" type="hidden">
|
||||
<div class="submit">
|
||||
<input name="submit" type="submit" value="${_('Submit')}" id="feedback_submit">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="feedback_success_wrapper">
|
||||
|
||||
<header>
|
||||
<h2>${_('Thank You!')}</h2>
|
||||
<hr>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
${Text(_(
|
||||
'Thank you for your inquiry or feedback. We typically respond to a request '
|
||||
'within one business day, Monday to Friday. In the meantime, please '
|
||||
'review our {link_start}detailed FAQs{link_end} where most questions have '
|
||||
'already been answered.'
|
||||
)).format(
|
||||
link_start=HTML('<a href="{}" target="_blank" id="success-feedback-faq-link">').format(marketing_link('FAQ')),
|
||||
link_end=HTML('</a>')
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
var currentCourseId,
|
||||
courseOptions = [],
|
||||
userAuthenticated = false,
|
||||
courseOptionsLoadInProgress = false,
|
||||
finishedLoadingCourseOptions = false,
|
||||
$helpModal = $("#help-modal"),
|
||||
$closeButton = $("#help-modal .close-modal"),
|
||||
$leanOverlay = $("#lean_overlay"),
|
||||
$feedbackForm = $("#feedback_form"),
|
||||
onModalClose = function() {
|
||||
$closeButton.off("click");
|
||||
$leanOverlay.off("click");
|
||||
$helpModal.attr("aria-hidden", "true");
|
||||
$('area,input,select,textarea,button').removeAttr('tabindex');
|
||||
$(".help-tab a").focus();
|
||||
$leanOverlay.removeAttr('tabindex');
|
||||
},
|
||||
showFeedback = function(event, issue_type, title, subject_label, details_label) {
|
||||
event.preventDefault();
|
||||
DialogTabControls.initializeTabKeyValues("#feedback_form_wrapper", $closeButton);
|
||||
$("#feedback_form input[name='issue_type']").val(issue_type);
|
||||
$("#feedback_form_wrapper header").html("<h2>" + title + "</h2><hr>");
|
||||
$("#feedback_form_wrapper label[data-field='subject']").html(subject_label);
|
||||
$("#feedback_form_wrapper label[data-field='details']").html(details_label);
|
||||
if (userAuthenticated && finishedLoadingCourseOptions && courseOptions.length > 1) {
|
||||
$('.js-course-id-anchor').html([
|
||||
'<label for="feedback_form_course">' + '${_("Course") | n, js_escaped_string}' + '</label>',
|
||||
'<select name="course_id" id="feedback_form_course" class="feedback-form-select">',
|
||||
courseOptions.join(''),
|
||||
'</select>'
|
||||
].join(''));
|
||||
}
|
||||
$("#help_wrapper").css("display", "none");
|
||||
$("#feedback_form_wrapper").css("display", "block");
|
||||
$closeButton.focus();
|
||||
},
|
||||
loadCourseOptions = function() {
|
||||
courseOptionsLoadInProgress = true;
|
||||
$.ajax({
|
||||
url: '/api/enrollment/v1/enrollment',
|
||||
success: function(data) {
|
||||
var i,
|
||||
courseDetails,
|
||||
courseName,
|
||||
courseId,
|
||||
option,
|
||||
defaultOptionText = '${_("- Select -") | n, js_escaped_string}',
|
||||
markedSelectedOption = false;
|
||||
|
||||
// Make sure courseOptions is empty before we begin pushing options into it.
|
||||
courseOptions = [];
|
||||
|
||||
for (i = 0; i < data.length; i++) {
|
||||
courseDetails = data[i].course_details;
|
||||
if (!courseDetails) {
|
||||
continue;
|
||||
}
|
||||
|
||||
courseName = courseDetails.course_name;
|
||||
courseId = courseDetails.course_id;
|
||||
if (!(courseName && courseId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build an option for this course and select it if it's the course we're currently viewing.
|
||||
if (!markedSelectedOption && courseId === currentCourseId) {
|
||||
option = buildCourseOption(courseName, courseId, true);
|
||||
markedSelectedOption = true;
|
||||
} else {
|
||||
option = buildCourseOption(courseName, courseId, false);
|
||||
}
|
||||
|
||||
courseOptions.push(option);
|
||||
}
|
||||
|
||||
// Build the default option and select it if we haven't already selected another option.
|
||||
option = buildCourseOption(defaultOptionText, '', !markedSelectedOption);
|
||||
|
||||
// Add the default option to the head of the courseOptions Array.
|
||||
courseOptions.unshift(option);
|
||||
|
||||
finishedLoadingCourseOptions = true;
|
||||
},
|
||||
complete: function() {
|
||||
courseOptionsLoadInProgress = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
buildCourseOption = function(courseName, courseId, selected) {
|
||||
var option = '<option value="' + _.escape(courseId) + '"';
|
||||
if (selected) {
|
||||
option += ' selected';
|
||||
}
|
||||
option += '>' + _.escape(courseName) + '</option>';
|
||||
return option;
|
||||
};
|
||||
|
||||
% if user.is_authenticated:
|
||||
userAuthenticated = true;
|
||||
% endif
|
||||
|
||||
% if course:
|
||||
currentCourseId = "${unicode(course.id) | n, js_escaped_string}";
|
||||
% endif
|
||||
|
||||
DialogTabControls.setKeydownListener($helpModal, $closeButton);
|
||||
|
||||
$(".help-tab").click(function() {
|
||||
if (userAuthenticated && !finishedLoadingCourseOptions && !courseOptionsLoadInProgress) {
|
||||
loadCourseOptions();
|
||||
}
|
||||
$helpModal.css("position", "absolute");
|
||||
DialogTabControls.initializeTabKeyValues("#help_wrapper", $closeButton);
|
||||
$(".field-error").removeClass("field-error");
|
||||
$feedbackForm[0].reset();
|
||||
$("#feedback_form input[type='submit']").removeAttr("disabled");
|
||||
$("#feedback_form_wrapper").css("display", "none");
|
||||
$("#feedback_error").css("display", "none");
|
||||
$("#feedback_form_details_tip").css("display", "none");
|
||||
$("#feedback_success_wrapper").css("display", "none");
|
||||
$("#help_wrapper").css("display", "block");
|
||||
$helpModal.attr("aria-hidden", "false");
|
||||
$closeButton.click(onModalClose);
|
||||
$leanOverlay.click(onModalClose);
|
||||
$("button.close-modal").attr('tabindex', 0);
|
||||
$closeButton.focus();
|
||||
});
|
||||
|
||||
$("#feedback_link_problem").click(function(event) {
|
||||
$("#feedback_form_details_tip").css({"display": "block", "padding-bottom": "5px"});
|
||||
showFeedback(
|
||||
event,
|
||||
"${_('problem') | n, js_escaped_string}",
|
||||
"${_('Report a Problem') | n, js_escaped_string}",
|
||||
"${_('Brief description of the problem') + '*' | n, js_escaped_string}" ,
|
||||
"${Text(_('Details of the problem you are encountering{asterisk}')).format(
|
||||
asterisk='*',
|
||||
) | n, js_escaped_string}"
|
||||
);
|
||||
});
|
||||
$("#feedback_link_suggestion").click(function(event) {
|
||||
showFeedback(
|
||||
event,
|
||||
"${_('suggestion') | n, js_escaped_string}",
|
||||
"${_('Make a Suggestion') | n, js_escaped_string}",
|
||||
"${_('Brief description of your suggestion') + '*' | n, js_escaped_string}",
|
||||
"${_('Details') + '*' | n, js_escaped_string}"
|
||||
);
|
||||
});
|
||||
$("#feedback_link_question").click(function(event) {
|
||||
showFeedback(
|
||||
event,
|
||||
"${_('question') | n, js_escaped_string}",
|
||||
"${_('Ask a Question') | n, js_escaped_string}",
|
||||
"${_('Brief summary of your question') + '*' | n, js_escaped_string}",
|
||||
"${_('Details') + '*' | n, js_escaped_string}"
|
||||
);
|
||||
});
|
||||
$feedbackForm.submit(function() {
|
||||
$("input[type='submit']", this).attr("disabled", "disabled");
|
||||
$closeButton.focus();
|
||||
});
|
||||
$feedbackForm.on("ajax:complete", function() {
|
||||
$("input[type='submit']", this).removeAttr("disabled");
|
||||
});
|
||||
$feedbackForm.on("ajax:success", function(event, data, status, xhr) {
|
||||
$("#feedback_form_wrapper").css("display", "none");
|
||||
$("#feedback_success_wrapper").css("display", "block");
|
||||
DialogTabControls.initializeTabKeyValues("#feedback_success_wrapper", $closeButton);
|
||||
$closeButton.focus();
|
||||
});
|
||||
$feedbackForm.on("ajax:error", function(event, xhr, status, error) {
|
||||
$(".field-error").removeClass("field-error").removeAttr("aria-invalid");
|
||||
var responseData;
|
||||
try {
|
||||
responseData = jQuery.parseJSON(xhr.responseText);
|
||||
} catch(err) {
|
||||
}
|
||||
if (responseData) {
|
||||
$("[data-field='"+responseData.field+"']").addClass("field-error").attr("aria-invalid", "true");
|
||||
$("#feedback_error").html(responseData.error).stop().css("display", "block");
|
||||
} else {
|
||||
// If no data (or malformed data) is returned, a server error occurred
|
||||
htmlStr = "${_('An error has occurred.') | n, js_escaped_string}";
|
||||
% if settings.FEEDBACK_SUBMISSION_EMAIL:
|
||||
htmlStr += " " + "${Text(_('Please {link_start}send us e-mail{link_end}.')).format(
|
||||
link_start=HTML('<button type="button" id="feedback_email">'),
|
||||
link_end=HTML('</button>'),
|
||||
) | n, js_escaped_string}";
|
||||
% else:
|
||||
// If no email is configured, we can't do much other than
|
||||
// ask the user to try again later
|
||||
htmlStr += " " + "${_('Please try again later.') | n, js_escaped_string}";
|
||||
% endif
|
||||
$("#feedback_error").html(htmlStr).stop().css("display", "block");
|
||||
% if settings.FEEDBACK_SUBMISSION_EMAIL:
|
||||
$("#feedback_email").click(function(e) {
|
||||
mailto = "mailto:" + "${settings.FEEDBACK_SUBMISSION_EMAIL | n, js_escaped_string}" +
|
||||
"?subject=" + $("#feedback_form input[name='subject']").val() +
|
||||
"&body=" + $("#feedback_form textarea[name='details']").val();
|
||||
window.open(mailto);
|
||||
e.preventDefault();
|
||||
});
|
||||
%endif
|
||||
}
|
||||
// Make change explicit to assistive technology
|
||||
$("#feedback_error").focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
%endif
|
||||
@@ -98,3 +98,8 @@ from openedx.core.djangoapps.lang_pref.api import header_language_selector_is_en
|
||||
)}</div>
|
||||
<![endif]-->
|
||||
% endif
|
||||
|
||||
<%include file="../help_modal.html"/>
|
||||
% if settings.FEATURES.get('ENABLE_COOKIE_CONSENT', False):
|
||||
<%include file="../widgets/cookie-consent.html" />
|
||||
% endif
|
||||
|
||||
24
lms/tests.py
24
lms/tests.py
@@ -7,9 +7,15 @@ import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from mock import patch
|
||||
from six import text_type
|
||||
|
||||
from edxmako import LOOKUP, add_lookup
|
||||
from microsite_configuration import microsite
|
||||
from openedx.features.course_experience import course_home_url_name
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,3 +56,21 @@ class TemplateLookupTests(TestCase):
|
||||
microsite.enable_microsites(log)
|
||||
directories = LOOKUP['main'].directories
|
||||
self.assertEqual(len([directory for directory in directories if 'external_module' in directory]), 1)
|
||||
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_FEEDBACK_SUBMISSION': True})
|
||||
class HelpModalTests(ModuleStoreTestCase):
|
||||
"""Tests for the help modal"""
|
||||
|
||||
def setUp(self):
|
||||
super(HelpModalTests, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
|
||||
def test_simple_test(self):
|
||||
"""
|
||||
Simple test to make sure that you don't get a 500 error when the modal
|
||||
is enabled.
|
||||
"""
|
||||
url = reverse(course_home_url_name(self.course.id), args=[text_type(self.course.id)])
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@@ -90,6 +90,9 @@ urlpatterns = [
|
||||
|
||||
url(r'^i18n/', include('django.conf.urls.i18n')),
|
||||
|
||||
# Feedback Form endpoint
|
||||
url(r'^submit_feedback$', util_views.submit_feedback),
|
||||
|
||||
# Enrollment API RESTful endpoints
|
||||
url(r'^api/enrollment/v1/', include('openedx.core.djangoapps.enrollments.urls')),
|
||||
|
||||
|
||||
@@ -3,15 +3,11 @@ Tests of Zendesk interaction utility functions
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
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, get_zendesk_group_by_name
|
||||
|
||||
from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
|
||||
from openedx.core.lib.api.test_utils import ApiTestCase
|
||||
|
||||
|
||||
@@ -65,51 +61,3 @@ class TestUtils(ApiTestCase):
|
||||
body=self.request_data['body'],
|
||||
)
|
||||
self.assertEqual(status_code, 500)
|
||||
|
||||
def test_financial_assistant_ticket(self):
|
||||
""" Test Financial Assistent request ticket. """
|
||||
ticket_creation_response_data = {
|
||||
"ticket": {
|
||||
"id": 35436,
|
||||
"subject": "My printer is on fire!",
|
||||
}
|
||||
}
|
||||
response_text = json.dumps(ticket_creation_response_data)
|
||||
with patch('requests.post', return_value=MagicMock(status_code=200, text=response_text)):
|
||||
with patch('requests.put', return_value=MagicMock(status_code=200)):
|
||||
with patch('openedx.core.djangoapps.zendesk_proxy.utils.get_zendesk_group_by_name', return_value=2):
|
||||
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'],
|
||||
group='Financial Assistant',
|
||||
additional_info=OrderedDict(
|
||||
(
|
||||
('Username', 'test'),
|
||||
('Full Name', 'Legal Name'),
|
||||
('Course ID', 'course_key'),
|
||||
('Annual Household Income', 'Income'),
|
||||
('Country', 'Country'),
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(status_code, 200)
|
||||
|
||||
def test_get_zendesk_group_by_name(self):
|
||||
""" Tests the functionality of the get zendesk group. """
|
||||
response_data = {
|
||||
"groups": [
|
||||
{
|
||||
"name": "DJs",
|
||||
"created_at": "2009-05-13T00:07:08Z",
|
||||
"updated_at": "2011-07-22T00:11:12Z",
|
||||
"id": 211
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response_text = json.dumps(response_data)
|
||||
with patch('requests.get', return_value=MagicMock(status_code=200, text=response_text)):
|
||||
group_id = get_zendesk_group_by_name('DJs')
|
||||
self.assertEqual(group_id, 211)
|
||||
|
||||
@@ -3,44 +3,29 @@ Utility functions for zendesk interaction.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from rest_framework import status
|
||||
from six.moves.urllib.parse import urljoin # pylint: disable=import-error
|
||||
|
||||
from django.conf import settings
|
||||
import requests
|
||||
from rest_framework import status
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _std_error_message(details, payload):
|
||||
"""Internal helper to standardize error message. This allows for simpler splunk alerts."""
|
||||
return u'zendesk_proxy action required\n{}\nNo ticket created for payload {}'.format(details, payload)
|
||||
|
||||
|
||||
def _get_request_headers():
|
||||
return {
|
||||
'content-type': 'application/json',
|
||||
'Authorization': u"Bearer {}".format(settings.ZENDESK_OAUTH_ACCESS_TOKEN),
|
||||
}
|
||||
|
||||
|
||||
def create_zendesk_ticket(
|
||||
requester_name,
|
||||
requester_email,
|
||||
subject,
|
||||
body,
|
||||
group=None,
|
||||
custom_fields=None,
|
||||
uploads=None,
|
||||
tags=None,
|
||||
additional_info=None
|
||||
):
|
||||
def create_zendesk_ticket(requester_name, requester_email, subject, body, custom_fields=None, uploads=None, tags=None):
|
||||
"""
|
||||
Create a Zendesk ticket via API.
|
||||
|
||||
Note that we do this differently in other locations (lms/djangoapps/commerce/signals.py and
|
||||
common/djangoapps/util/views.py). Both of those callers use basic auth, and should be switched over to this oauth
|
||||
implementation once the immediate pressures of zendesk_proxy are resolved.
|
||||
"""
|
||||
def _std_error_message(details, payload):
|
||||
"""Internal helper to standardize error message. This allows for simpler splunk alerts."""
|
||||
return u'zendesk_proxy action required\n{}\nNo ticket created for payload {}'.format(details, payload)
|
||||
|
||||
if tags:
|
||||
# Remove duplicates from tags list
|
||||
tags = list(set(tags))
|
||||
@@ -61,22 +46,22 @@ def create_zendesk_ticket(
|
||||
}
|
||||
}
|
||||
|
||||
if not (settings.ZENDESK_URL and settings.ZENDESK_OAUTH_ACCESS_TOKEN):
|
||||
log.error(_std_error_message("zendesk not configured", data))
|
||||
return status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
if group:
|
||||
group_id = get_zendesk_group_by_name(group)
|
||||
data['ticket']['group_id'] = group_id
|
||||
|
||||
# Encode the data to create a JSON payload
|
||||
payload = json.dumps(data)
|
||||
|
||||
if not (settings.ZENDESK_URL and settings.ZENDESK_OAUTH_ACCESS_TOKEN):
|
||||
log.error(_std_error_message("zendesk not configured", payload))
|
||||
return status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
# Set the request parameters
|
||||
url = urljoin(settings.ZENDESK_URL, '/api/v2/tickets.json')
|
||||
headers = {
|
||||
'content-type': 'application/json',
|
||||
'Authorization': u"Bearer {}".format(settings.ZENDESK_OAUTH_ACCESS_TOKEN),
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=payload, headers=_get_request_headers())
|
||||
response = requests.post(url, data=payload, headers=headers)
|
||||
|
||||
# Check for HTTP codes other than 201 (Created)
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
@@ -88,72 +73,7 @@ def create_zendesk_ticket(
|
||||
payload
|
||||
)
|
||||
)
|
||||
if additional_info:
|
||||
ticket = json.loads(response.text)['ticket']
|
||||
return post_additional_info_as_comment(ticket['id'], additional_info)
|
||||
|
||||
return response.status_code
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception(_std_error_message('Internal server error', payload))
|
||||
return status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
def get_zendesk_group_by_name(name):
|
||||
"""
|
||||
Calls the Zendesk list-groups api
|
||||
|
||||
Returns the group Id matching the name.
|
||||
"""
|
||||
url = urljoin(settings.ZENDESK_URL, '/api/v2/groups.json')
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=_get_request_headers())
|
||||
|
||||
groups = json.loads(response.text)['groups']
|
||||
for group in groups:
|
||||
if group['name'] == name:
|
||||
return group['id']
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception(_std_error_message('Internal server error', 'None'))
|
||||
|
||||
return status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
log.exception(_std_error_message('Tried to get zendesk group which does not exist', name))
|
||||
raise Exception
|
||||
|
||||
|
||||
def post_additional_info_as_comment(ticket_id, additional_info):
|
||||
"""
|
||||
Post the Additional Provided as a comment, So that it is only visible
|
||||
to management and not students.
|
||||
"""
|
||||
additional_info_string = (
|
||||
u"Additional information:\n\n" +
|
||||
u"\n".join(u"%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None)
|
||||
)
|
||||
|
||||
data = {
|
||||
'ticket': {
|
||||
'comment': {
|
||||
'body': additional_info_string,
|
||||
'publuc': False
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
url = urljoin(settings.ZENDESK_URL, 'api/v2/tickets/{}.json'.format(ticket_id))
|
||||
|
||||
try:
|
||||
response = requests.put(url, data=json.dumps(data), headers=_get_request_headers())
|
||||
if response.status_code >= 200 and response.status_code < 300:
|
||||
log.debug(u'Successfully created comment for ticket {}'.format(ticket_id))
|
||||
else:
|
||||
log.error(
|
||||
_std_error_message(
|
||||
u'Unexpected response: {} - {}'.format(response.status_code, response.content),
|
||||
data
|
||||
)
|
||||
)
|
||||
return response.status_code
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception(_std_error_message('Internal server error', data))
|
||||
return status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
@@ -153,5 +153,6 @@ web-fragments # Provides the ability to render fragments o
|
||||
XBlock # Courseware component architecture
|
||||
xblock-utils # Provides utilities used by the Discussion XBlock
|
||||
xss-utils # https://github.com/edx/edx-platform/pull/20633 Fix XSS via Translations
|
||||
zendesk # Python API for the Zendesk customer support system
|
||||
geoip2==2.9.0 # Python API for the GeoIP web services and databases
|
||||
edx-bulk-grades # LMS REST API for managing bulk grading operations
|
||||
|
||||
@@ -222,7 +222,7 @@ scipy==1.2.1
|
||||
semantic-version==2.6.0 # via edx-drf-extensions
|
||||
shapely==1.6.4.post2
|
||||
shortuuid==0.5.0 # via edx-django-oauth2-provider
|
||||
simplejson==3.16.0 # via mailsnake, sailthru-client
|
||||
simplejson==3.16.0 # via mailsnake, sailthru-client, zendesk
|
||||
singledispatch==3.4.0.3
|
||||
six==1.11.0
|
||||
slumber==0.7.1 # via edx-enterprise, edx-rest-api-client
|
||||
@@ -254,6 +254,7 @@ xblock-utils==1.2.2
|
||||
xblock==1.2.3
|
||||
xmlsec==1.3.3 # via python3-saml
|
||||
xss-utils==0.1.1
|
||||
zendesk==1.1.1
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# setuptools==41.0.1 # via fs, lazy, python-levenshtein
|
||||
|
||||
@@ -349,6 +349,7 @@ xblock==1.2.3
|
||||
xmlsec==1.3.3
|
||||
xmltodict==0.12.0
|
||||
xss-utils==0.1.1
|
||||
zendesk==1.1.1
|
||||
zipp==0.5.2
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
|
||||
@@ -335,6 +335,7 @@ xblock==1.2.3
|
||||
xmlsec==1.3.3
|
||||
xmltodict==0.12.0 # via moto
|
||||
xss-utils==0.1.1
|
||||
zendesk==1.1.1
|
||||
zipp==0.5.2 # via importlib-metadata
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
|
||||
Reference in New Issue
Block a user