Commit 87072a9a by Greg Price

Add an endpoint for submission of Zendesk tickets by end users

This functionality requires the Zendesk URL, user, and API key to be specified
in django.conf.settings. Also, add a flag to MITX_FEATURES (enabled by default)
to control the endpoint and the front-end feature (yet to be added).
parent 0f7378a1
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
"""
"""Tests for the util package"""
from django.conf import settings
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 student.tests.factories import UserFactory
from util import views
from zendesk import ZendeskError
import json
import mock
@mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True})
@override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy")
@mock.patch("util.views._ZendeskApi", autospec=True)
class SubmitFeedbackViaZendeskTest(TestCase):
def setUp(self):
"""Set up data for the test case"""
self._request_factory = RequestFactory()
self._anon_user = AnonymousUser()
self._auth_user = UserFactory.create(
email="test@edx.org",
username="test",
profile__name="Test User"
)
# This contains a tag to ensure that tags are submitted correctly
self._anon_fields = {
"email": "test@edx.org",
"name": "Test User",
"subject": "a subject",
"details": "some details",
"tag": "a tag"
}
# This does not contain a tag to ensure that tag is optional
self._auth_fields = {"subject": "a subject", "details": "some details"}
def _test_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_referer",
HTTP_USER_AGENT="test_user_agent"
)
req.user = user
return views.submit_feedback_via_zendesk(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.assertTrue("field" in resp_json)
self.assertEqual(resp_json["field"], field)
self.assertTrue("error" in 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._test_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.
class SimpleTest(TestCase):
def test_basic_addition(self):
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.
"""
Tests that 1 + 1 always equals 2.
altered_fields = fields.copy()
altered_fields[empty_field] = ""
resp = self._test_request(user, altered_fields)
self._assert_bad_request(resp, empty_field, zendesk_mock_class)
def _test_success(self, user, fields):
"""
self.assertEqual(1 + 1, 2)
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._test_request(user, fields)
self.assertEqual(resp.status_code, 200)
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_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
zendesk_mock_instance.create_ticket.return_value = 42
self._test_success(self._anon_user, self._anon_fields)
expected_calls = [
mock.call.create_ticket(
{
"ticket": {
"requester": {"name": "Test User", "email": "test@edx.org"},
"subject": "a subject",
"comment": {"body": "some details"},
"tags": ["a tag"]
}
}
),
mock.call.update_ticket(
42,
{
"ticket": {
"comment": {
"public": False,
"body":
"Additional information:\n\n"
"HTTP_USER_AGENT: test_user_agent\n"
"HTTP_REFERER: test_referer"
}
}
}
)
]
self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls)
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
zendesk_mock_instance.create_ticket.return_value = 42
self._test_success(self._auth_user, self._auth_fields)
expected_calls = [
mock.call.create_ticket(
{
"ticket": {
"requester": {"name": "Test User", "email": "test@edx.org"},
"subject": "a subject",
"comment": {"body": "some details"},
"tags": []
}
}
),
mock.call.update_ticket(
42,
{
"ticket": {
"comment": {
"public": False,
"body":
"Additional information:\n\n"
"username: test\n"
"HTTP_USER_AGENT: test_user_agent\n"
"HTTP_REFERER: test_referer"
}
}
}
)
]
self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls)
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_via_zendesk(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._test_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._test_request(self._anon_user, self._anon_fields)
self.assertEqual(resp.status_code, 200)
@mock.patch.dict("django.conf.settings.MITX_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._test_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._test_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")
import datetime
import json
import logging
import pprint
import sys
......@@ -7,15 +8,21 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.core.context_processors import csrf
from django.core.mail import send_mail
from django.http import Http404
from django.http import HttpResponse
from django.core.validators import ValidationError, validate_email
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError
from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response, render_to_string
from urllib import urlencode
import zendesk
import capa.calc
import track.views
log = logging.getLogger(__name__)
def calculate(request):
''' Calculator in footer of every page. '''
equation = request.GET['equation']
......@@ -29,36 +36,142 @@ def calculate(request):
return HttpResponse(json.dumps({'result': str(result)}))
def send_feedback(request):
''' Feeback mechanism in footer of every page. '''
try:
username = request.user.username
email = request.user.email
except:
username = "anonymous"
email = "anonymous"
class _ZendeskApi(object):
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
)
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 submit_feedback_via_zendesk(request):
"""
Create a new user-requested Zendesk ticket.
If Zendesk 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 returns any error on ticket
creation, a 500 error will be returned with no body. 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. Whether or not the update succeeds, if the user's ticket is
successfully created, an empty successful response (200) will be returned.
"""
if not settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False):
raise Http404()
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
if (
not settings.ZENDESK_URL or
not settings.ZENDESK_USER or
not settings.ZENDESK_API_KEY
):
raise Exception("Zendesk enabled but not configured")
def build_error_response(status_code, field, err_msg):
return HttpResponse(json.dumps({"field": field, "error": err_msg}), status=status_code)
additional_info = {}
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])
subject = request.POST["subject"]
details = request.POST["details"]
tags = []
if "tag" in request.POST:
tags = [request.POST["tag"]]
if request.user.is_authenticated():
realname = request.user.profile.name
email = request.user.email
additional_info["username"] = request.user.username
else:
realname = request.POST["name"]
email = request.POST["email"]
try:
validate_email(email)
except ValidationError:
return build_error_response(400, "email", required_field_errs["email"])
for header in ["HTTP_REFERER", "HTTP_USER_AGENT"]:
additional_info[header] = request.META.get(header)
zendesk_api = _ZendeskApi()
additional_info_string = (
"Additional information:\n\n" +
"\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None)
)
new_ticket = {
"ticket": {
"requester": {"name": realname, "email": email},
"subject": subject,
"comment": {"body": details},
"tags": tags
}
}
try:
browser = request.META['HTTP_USER_AGENT']
except:
browser = "Unknown"
feedback = render_to_string("feedback_email.txt",
{"subject": request.POST['subject'],
"url": request.POST['url'],
"time": datetime.datetime.now().isoformat(),
"feedback": request.POST['message'],
"email": email,
"browser": browser,
"user": username})
send_mail("MITx Feedback / " + request.POST['subject'],
feedback,
settings.DEFAULT_FROM_EMAIL,
[settings.DEFAULT_FEEDBACK_EMAIL],
fail_silently=False
)
return HttpResponse(json.dumps({'success': True}))
ticket_id = zendesk_api.create_ticket(new_ticket)
except zendesk.ZendeskError as err:
log.error("%s", str(err))
return HttpResponse(status=500)
# 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 as err:
log.error("%s", str(err))
# The update is not strictly necessary, so do not indicate failure to the user
pass
return HttpResponse()
def info(request):
......
......@@ -5,6 +5,7 @@
-e git://github.com/edx/django-pipeline.git#egg=django-pipeline
-e git://github.com/edx/django-wiki.git@e2e84558#egg=django-wiki
-e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev
-e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
# Our libraries:
-e git+https://github.com/edx/XBlock.git@5ce6f70a#egg=XBlock
......@@ -80,6 +80,8 @@ META_UNIVERSITIES = ENV_TOKENS.get('META_UNIVERSITIES', {})
COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '')
COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '')
CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull')
ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL")
FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL")
############################## SECURE AUTH ITEMS ###############
# Secret things: passwords, access keys, etc.
......@@ -115,3 +117,6 @@ DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
# Analytics dashboard server
ANALYTICS_SERVER_URL = ENV_TOKENS.get("ANALYTICS_SERVER_URL")
ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", "")
ZENDESK_USER = AUTH_TOKENS.get("ZENDESK_USER")
ZENDESK_API_KEY = AUTH_TOKENS.get("ZENDESK_API_KEY")
......@@ -90,7 +90,10 @@ MITX_FEATURES = {
# Give a UI to show a student's submission history in a problem by the
# Staff Debug tool.
'ENABLE_STUDENT_HISTORY_VIEW': True
'ENABLE_STUDENT_HISTORY_VIEW': True,
# Provide a UI to allow users to submit feedback from the LMS
'ENABLE_FEEDBACK_SUBMISSION': False,
}
# Used for A/B testing
......@@ -323,6 +326,14 @@ WIKI_LINK_DEFAULT_LEVEL = 2
PEARSONVUE_SIGNINPAGE_URL = "https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX"
# TESTCENTER_ACCOMMODATION_REQUEST_EMAIL = "exam-help@edx.org"
##### Feedback submission mechanism #####
FEEDBACK_SUBMISSION_EMAIL = None
##### Zendesk #####
ZENDESK_URL = None
ZENDESK_USER = None
ZENDESK_API_KEY = None
################################# open ended grading config #####################
#By setting up the default settings with an incorrect user name and password,
......@@ -582,3 +593,4 @@ INSTALLED_APPS = (
# Discussion forums
'django_comment_client',
)
......@@ -116,8 +116,9 @@ urlpatterns = ('',
# Favicon
(r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}),
url(r'^submit_feedback$', 'util.views.submit_feedback_via_zendesk'),
# TODO: These urls no longer work. They need to be updated before they are re-enabled
# url(r'^send_feedback$', 'util.views.send_feedback'),
# url(r'^reactivate/(?P<key>[^/]*)$', 'student.views.reactivation_email'),
)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment