diff --git a/common/djangoapps/util/tests/test_submit_feedback.py b/common/djangoapps/util/tests/test_submit_feedback.py index 077765e1d7..f3969a7b55 100644 --- a/common/djangoapps/util/tests/test_submit_feedback.py +++ b/common/djangoapps/util/tests/test_submit_feedback.py @@ -11,8 +11,10 @@ from util import views from zendesk import ZendeskError import json import mock +from ddt import ddt, data, unpack from student.tests.test_configuration_overrides import fake_get_value +from student.tests.factories import CourseEnrollmentFactory def fake_support_backend_values(name, default=None): # pylint: disable=unused-argument @@ -26,8 +28,9 @@ def fake_support_backend_values(name, default=None): # pylint: disable=unused-a return config_dict[name] +@ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True}) -@override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy") +@override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy", ZENDESK_CUSTOM_FIELDS={}) @mock.patch("util.views.dog_stats_api") @mock.patch("util.views._ZendeskApi", autospec=True) class SubmitFeedbackTest(TestCase): @@ -44,14 +47,12 @@ class SubmitFeedbackTest(TestCase): username="test", profile__name="Test User" ) - # This contains issue_type and course_id to ensure that tags are submitted correctly self._anon_fields = { "email": "test@edx.org", "name": "Test User", "subject": "a subject", "details": "some details", - "issue_type": "test_issue", - "course_id": "test_course" + "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"} @@ -130,13 +131,9 @@ class SubmitFeedbackTest(TestCase): resp = self._build_and_run_request(user, fields) self.assertEqual(resp.status_code, 200) - def _assert_datadog_called(self, datadog_mock, with_tags): - expected_datadog_calls = [ - mock.call.increment( - views.DATADOG_FEEDBACK_METRIC, - tags=(["course_id:test_course", "issue_type:test_issue"] if with_tags else []) - ) - ] + def _assert_datadog_called(self, datadog_mock, tags): + """Assert that datadog was called with the correct tags.""" + expected_datadog_calls = [mock.call.increment(views.DATADOG_FEEDBACK_METRIC, tags=tags)] self.assertEqual(datadog_mock.mock_calls, expected_datadog_calls) def test_bad_request_anon_user_no_name(self, zendesk_mock_class, datadog_mock): @@ -184,7 +181,7 @@ class SubmitFeedbackTest(TestCase): "requester": {"name": "Test User", "email": "test@edx.org"}, "subject": "a subject", "comment": {"body": "some details"}, - "tags": ["test_course", "test_issue", "LMS"] + "tags": ["test_issue", "LMS"] } } ), @@ -206,7 +203,7 @@ class SubmitFeedbackTest(TestCase): ) ] self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) - self._assert_datadog_called(datadog_mock, with_tags=True) + self._assert_datadog_called(datadog_mock, ["issue_type:test_issue"]) @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, datadog_mock): @@ -228,7 +225,7 @@ class SubmitFeedbackTest(TestCase): "requester": {"name": "Test User", "email": "test@edx.org"}, "subject": "a subject", "comment": {"body": "some details"}, - "tags": ["test_course", "test_issue", "LMS", "whitelabel_fakeorg"] + "tags": ["test_issue", "LMS", "whitelabel_fakeorg"] } } ), @@ -250,7 +247,69 @@ class SubmitFeedbackTest(TestCase): ) ] self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) - self._assert_datadog_called(datadog_mock, with_tags=True) + self._assert_datadog_called(datadog_mock, ["issue_type:test_issue"]) + + @data("course-v1:testOrg+testCourseNumber+testCourseRun", "", None) + @override_settings(ZENDESK_CUSTOM_FIELDS={"course_id": 1234, "enrollment_mode": 5678}) + def test_valid_request_anon_user_with_custom_fields(self, course_id, zendesk_mock, datadog_mock): + """ + 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.return_value + zendesk_mock_instance.create_ticket.return_value = 42 + + fields = self._anon_fields.copy() + + expected_zendesk_tags = None + expected_datadog_tags = None + if course_id is not None: + fields["course_id"] = course_id + expected_zendesk_tags = [course_id, "test_issue", "LMS"] + expected_datadog_tags = ["course_id:{}".format(course_id), "issue_type:test_issue"] + else: + expected_zendesk_tags = ["test_issue", "LMS"] + expected_datadog_tags = ["issue_type:test_issue"] + + expected_create_ticket_request = { + "ticket": { + "recipient": "registration@example.com", + "requester": {"name": "Test User", "email": "test@edx.org"}, + "subject": "a subject", + "comment": {"body": "some details"}, + "tags": expected_zendesk_tags + } + } + + expected_update_ticket_request = { + "ticket": { + "comment": { + "public": False, + "body": + "Additional information:\n\n" + "Client IP: 1.2.3.4\n" + "Host: test_server\n" + "Page: test_referer\n" + "Browser: test_user_agent" + } + } + } + + if fields.get("course_id"): + expected_custom_fields = [{"id": 1234, "value": fields["course_id"]}] + expected_create_ticket_request["ticket"]["custom_fields"] = expected_custom_fields + + self._test_success(self._anon_user, fields) + expected_zendesk_calls = [ + mock.call.create_ticket(expected_create_ticket_request), + mock.call.update_ticket(42, expected_update_ticket_request) + ] + self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) + self._assert_datadog_called(datadog_mock, expected_datadog_tags) def test_bad_request_auth_user_no_subject(self, zendesk_mock_class, datadog_mock): """Test a request from an authenticated user not specifying `subject`.""" @@ -303,7 +362,85 @@ class SubmitFeedbackTest(TestCase): ) ] self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) - self._assert_datadog_called(datadog_mock, with_tags=False) + self._assert_datadog_called(datadog_mock, []) + + @data( + ("course-v1:testOrg+testCourseNumber+testCourseRun", True), + ("course-v1:testOrg+testCourseNumber+testCourseRun", False), + ("", None), + (None, None) + ) + @unpack + @override_settings(ZENDESK_CUSTOM_FIELDS={"course_id": 1234, "enrollment_mode": 5678}) + def test_valid_request_auth_user_with_custom_fields(self, course_id, enrollment_state, zendesk_mock, datadog_mock): + """ + 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.return_value + zendesk_mock_instance.create_ticket.return_value = 42 + + fields = self._auth_fields.copy() + + expected_zendesk_tags = None + expected_datadog_tags = None + if course_id is not None: + fields["course_id"] = course_id + expected_zendesk_tags = [course_id, "LMS"] + expected_datadog_tags = ["course_id:{}".format(course_id)] + else: + expected_zendesk_tags = ["LMS"] + expected_datadog_tags = [] + + expected_create_ticket_request = { + "ticket": { + "recipient": "registration@example.com", + "requester": {"name": "Test User", "email": "test@edx.org"}, + "subject": "a subject", + "comment": {"body": "some details"}, + "tags": expected_zendesk_tags + } + } + + expected_update_ticket_request = { + "ticket": { + "comment": { + "public": False, + "body": + "Additional information:\n\n" + "username: test\n" + "Client IP: 1.2.3.4\n" + "Host: test_server\n" + "Page: test_referer\n" + "Browser: test_user_agent", + } + } + } + + if fields.get("course_id"): + expected_custom_fields = [{"id": 1234, "value": fields["course_id"]}] + if enrollment_state is not None: + enrollment = CourseEnrollmentFactory.create( + user=self._auth_user, + course_id=course_id, + is_active=enrollment_state + ) + if enrollment.is_active: + expected_custom_fields.append({"id": 5678, "value": enrollment.mode}) + expected_create_ticket_request["ticket"]["custom_fields"] = expected_custom_fields + + self._test_success(self._auth_user, fields) + expected_zendesk_calls = [ + mock.call.create_ticket(expected_create_ticket_request), + mock.call.update_ticket(42, expected_update_ticket_request) + ] + self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) + self._assert_datadog_called(datadog_mock, expected_datadog_tags) def test_get_request(self, zendesk_mock_class, datadog_mock): """Test that a GET results in a 405 even with all required fields""" @@ -329,7 +466,7 @@ class SubmitFeedbackTest(TestCase): resp = self._build_and_run_request(self._anon_user, self._anon_fields) self.assertEqual(resp.status_code, 500) self.assertFalse(resp.content) - self._assert_datadog_called(datadog_mock, with_tags=True) + self._assert_datadog_called(datadog_mock, ["issue_type:test_issue"]) def test_zendesk_error_on_update(self, zendesk_mock_class, datadog_mock): """ @@ -344,7 +481,7 @@ class SubmitFeedbackTest(TestCase): 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) - self._assert_datadog_called(datadog_mock, with_tags=True) + self._assert_datadog_called(datadog_mock, ["issue_type:test_issue"]) @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": False}) def test_not_enabled(self, zendesk_mock_class, datadog_mock): diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index ea886e7777..c5560fedea 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -25,6 +25,7 @@ from edxmako.shortcuts import render_to_response, render_to_string from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers import track.views from student.roles import GlobalStaff +from student.models import CourseEnrollment log = logging.getLogger(__name__) @@ -222,6 +223,40 @@ class _ZendeskApi(object): return None +def _get_zendesk_custom_field_context(request): + """ + 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 + + 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, @@ -231,7 +266,8 @@ def _record_feedback_in_zendesk( additional_info, group_name=None, require_update=False, - support_email=None + support_email=None, + custom_fields=None ): """ Create a new user-requested Zendesk ticket. @@ -246,6 +282,8 @@ def _record_feedback_in_zendesk( 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() @@ -271,6 +309,10 @@ def _record_feedback_in_zendesk( "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) @@ -412,6 +454,11 @@ def submit_feedback(request): 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) + custom_fields = _format_zendesk_custom_fields(custom_field_context) + success = _record_feedback_in_zendesk( context["realname"], context["email"], @@ -419,7 +466,8 @@ def submit_feedback(request): context["details"], context["tags"], context["additional_info"], - support_email=context["support_email"] + support_email=context["support_email"], + custom_fields=custom_fields ) _record_feedback_in_datadog(context["tags"]) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 340c2079fa..3e85617b0f 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -333,6 +333,8 @@ COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') ZENDESK_URL = ENV_TOKENS.get('ZENDESK_URL', ZENDESK_URL) +ZENDESK_CUSTOM_FIELDS = ENV_TOKENS.get('ZENDESK_CUSTOM_FIELDS', ZENDESK_CUSTOM_FIELDS) + FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL") MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) diff --git a/lms/envs/common.py b/lms/envs/common.py index a3ef8fca31..e71ccdc03b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1003,6 +1003,7 @@ FEEDBACK_SUBMISSION_EMAIL = None ZENDESK_URL = None ZENDESK_USER = None ZENDESK_API_KEY = None +ZENDESK_CUSTOM_FIELDS = {} ##### EMBARGO ##### EMBARGO_SITE_REDIRECT_URL = None diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index bb376fec9f..460e2dbc2d 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -35,6 +35,7 @@ @import 'shared/modal'; @import 'shared/activation_messages'; @import 'shared/unsubscribe'; +@import 'shared/help-tab'; // shared - platform @import 'multicourse/home'; diff --git a/lms/static/sass/shared-v2/_help-tab.scss b/lms/static/sass/shared-v2/_help-tab.scss index 080c5f4e6c..99cf9b3836 100644 --- a/lms/static/sass/shared-v2/_help-tab.scss +++ b/lms/static/sass/shared-v2/_help-tab.scss @@ -78,3 +78,9 @@ padding: 0 $baseline $baseline; } } + +.feedback-form-select { + background: $white; + margin-bottom: $baseline; + width: 100%; +} diff --git a/lms/static/sass/shared/_help-tab.scss b/lms/static/sass/shared/_help-tab.scss new file mode 100644 index 0000000000..7af1407ac0 --- /dev/null +++ b/lms/static/sass/shared/_help-tab.scss @@ -0,0 +1,5 @@ +.feedback-form-select { + background: $white; + margin-bottom: $baseline; + width: 100%; +} diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html index abe1779d99..5198e893c1 100644 --- a/lms/templates/help_modal.html +++ b/lms/templates/help_modal.html @@ -99,15 +99,21 @@ from xmodule.tabs import CourseTabList % endif + +
+ % if course: + + % endif +
+ + ${_('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.')} + - % if course: - - % endif
@@ -138,7 +144,12 @@ from xmodule.tabs import CourseTabList