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