From 26a1445c938beadd1ec3b49f8d5781831ac1a80f Mon Sep 17 00:00:00 2001 From: Ayub <muhammadayubkhan6@gmail.com> Date: Thu, 8 Aug 2019 17:39:45 +0500 Subject: [PATCH] Revert "BOM-70" --- .../util/tests/test_submit_feedback.py | 617 ++++++++++++++++++ common/djangoapps/util/views.py | 335 +++++++++- lms/djangoapps/courseware/tests/test_views.py | 20 +- lms/djangoapps/courseware/views/views.py | 15 +- lms/envs/common.py | 3 + lms/templates/help_modal.html | 358 ++++++++++ lms/templates/navigation/navigation.html | 5 + lms/tests.py | 24 + lms/urls.py | 3 + .../zendesk_proxy/tests/test_utils.py | 58 +- .../core/djangoapps/zendesk_proxy/utils.py | 120 +--- requirements/edx/base.in | 1 + requirements/edx/base.txt | 3 +- requirements/edx/development.txt | 1 + requirements/edx/testing.txt | 1 + 15 files changed, 1388 insertions(+), 176 deletions(-) create mode 100644 common/djangoapps/util/tests/test_submit_feedback.py create mode 100644 lms/templates/help_modal.html diff --git a/common/djangoapps/util/tests/test_submit_feedback.py b/common/djangoapps/util/tests/test_submit_feedback.py new file mode 100644 index 00000000000..abfe3bf3f3c --- /dev/null +++ b/common/djangoapps/util/tests/test_submit_feedback.py @@ -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) diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index 68822649e2d..2ea29614fe5 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -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 diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index a991a87b708..4741995b2d9 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -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), diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index b6e581e63d1..f9be6c3c15f 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 69a963adc10..c3147699a2d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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, diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html new file mode 100644 index 00000000000..13703d2a431 --- /dev/null +++ b/lms/templates/help_modal.html @@ -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 diff --git a/lms/templates/navigation/navigation.html b/lms/templates/navigation/navigation.html index f38f3b6b94a..2e399419a99 100644 --- a/lms/templates/navigation/navigation.html +++ b/lms/templates/navigation/navigation.html @@ -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 diff --git a/lms/tests.py b/lms/tests.py index 43b7e9f5d89..6a573e20e1b 100644 --- a/lms/tests.py +++ b/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) diff --git a/lms/urls.py b/lms/urls.py index 12d8e879224..39d04ff99c5 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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')), diff --git a/openedx/core/djangoapps/zendesk_proxy/tests/test_utils.py b/openedx/core/djangoapps/zendesk_proxy/tests/test_utils.py index ec0ca6b757f..d0c7223aba7 100644 --- a/openedx/core/djangoapps/zendesk_proxy/tests/test_utils.py +++ b/openedx/core/djangoapps/zendesk_proxy/tests/test_utils.py @@ -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) diff --git a/openedx/core/djangoapps/zendesk_proxy/utils.py b/openedx/core/djangoapps/zendesk_proxy/utils.py index 50138fa3df5..d905b58b82b 100644 --- a/openedx/core/djangoapps/zendesk_proxy/utils.py +++ b/openedx/core/djangoapps/zendesk_proxy/utils.py @@ -3,44 +3,29 @@ Utility functions for zendesk interaction. """ from __future__ import absolute_import - import json import logging +from six.moves.urllib.parse import urljoin # pylint: disable=import-error -import requests from django.conf import settings +import requests from rest_framework import status -from six.moves.urllib.parse import urljoin # pylint: disable=import-error 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 diff --git a/requirements/edx/base.in b/requirements/edx/base.in index d33d9025450..8cdd7c7d829 100644 --- a/requirements/edx/base.in +++ b/requirements/edx/base.in @@ -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 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b3a985d7699..7337016f17b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 28d61486f0b..13e595262bf 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -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: diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 95ced22c4bb..15a72663b83 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -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: -- GitLab