Merge pull request #21314 from edx/revert-21229-BOM-70

Revert "BOM-70"
This commit is contained in:
Farhanah Sheets
2019-08-08 09:18:33 -04:00
committed by GitHub
15 changed files with 1390 additions and 178 deletions

View 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)

View File

@@ -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

View File

@@ -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),

View File

@@ -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)

View File

@@ -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,

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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')),

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View 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: