Commit ec3c8458 by Greg Price

Merge pull request #1911 from edx/feature/gprice/feedback-button

parents 66185423 203a958e
......@@ -2,7 +2,7 @@ from student.models import (User, UserProfile, Registration,
CourseEnrollmentAllowed, CourseEnrollment)
from django.contrib.auth.models import Group
from datetime import datetime
from factory import DjangoModelFactory, Factory, SubFactory, PostGenerationMethodCall
from factory import DjangoModelFactory, Factory, SubFactory, PostGenerationMethodCall, post_generation
from uuid import uuid4
......@@ -45,6 +45,16 @@ class UserFactory(DjangoModelFactory):
last_login = datetime(2012, 1, 1)
date_joined = datetime(2011, 1, 1)
@post_generation
def profile(obj, create, extracted, **kwargs):
if create:
obj.save()
return UserProfileFactory.create(user=obj, **kwargs)
elif kwargs:
raise Exception("Cannot build a user profile without saving the user")
else:
return None
class AdminFactory(UserFactory):
is_staff = True
......
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:
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
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)
)
return HttpResponse(json.dumps({'success': True}))
new_ticket = {
"ticket": {
"requester": {"name": realname, "email": email},
"subject": subject,
"comment": {"body": details},
"tags": tags
}
}
try:
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):
......
......@@ -37,11 +37,17 @@ class XModuleCourseFactory(Factory):
new_course.display_name = display_name
new_course.lms.start = gmtime()
new_course.tabs = [{"type": "courseware"},
new_course.tabs = kwargs.get(
'tabs',
[
{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
{"type": "progress", "name": "Progress"}
]
)
new_course.discussion_link = kwargs.get('discussion_link')
# Update the data in the mongo datastore
store.update_metadata(new_course.location.url(), own_metadata(new_course))
......
......@@ -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
......@@ -294,6 +294,27 @@ def get_course_tabs(user, course, active_page):
return tabs
def get_discussion_link(course):
"""
Return the URL for the discussion tab for the given `course`.
If they have a discussion link specified, use that even if we disable
discussions. Disabling discsussions is mostly a server safety feature at
this point, and we don't need to worry about external sites. Otherwise,
if the course has a discussion tab or uses the default tabs, return the
discussion view URL. Otherwise, return None to indicate the lack of a
discussion tab.
"""
if course.discussion_link:
return course.discussion_link
elif not settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
return None
elif hasattr(course, 'tabs') and course.tabs and not any([tab['type'] == 'discussion' for tab in course.tabs]):
return None
else:
return reverse('django_comment_client.forum.views.forum_form_discussion', args=[course.id])
def get_default_tabs(user, course, active_page):
# When calling the various _tab methods, can omit the 'type':'blah' from the
......@@ -308,15 +329,9 @@ def get_default_tabs(user, course, active_page):
tabs.extend(_textbooks({}, user, course, active_page))
## If they have a discussion link specified, use that even if we feature
## flag discussions off. Disabling that is mostly a server safety feature
## at this point, and we don't need to worry about external sites.
if course.discussion_link:
tabs.append(CourseTab('Discussion', course.discussion_link, active_page == 'discussion'))
elif settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
link = reverse('django_comment_client.forum.views.forum_form_discussion',
args=[course.id])
tabs.append(CourseTab('Discussion', link, active_page == 'discussion'))
discussion_link = get_discussion_link(course)
if discussion_link:
tabs.append(CourseTab('Discussion', discussion_link, active_page == 'discussion'))
tabs.extend(_wiki({'name': 'Wiki', 'type': 'wiki'}, user, course, active_page))
......
from django.test import TestCase
from mock import MagicMock
from mock import patch
import courseware.tabs as tabs
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
class ProgressTestCase(TestCase):
......@@ -257,3 +261,62 @@ class ValidateTabsTestCase(TestCase):
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2])
self.assertIsNone(tabs.validate_tabs(self.courses[3]))
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4])
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class DiscussionLinkTestCase(ModuleStoreTestCase):
def setUp(self):
self.tabs_with_discussion = [
{'type':'courseware'},
{'type':'course_info'},
{'type':'discussion'},
{'type':'textbooks'},
]
self.tabs_without_discussion = [
{'type':'courseware'},
{'type':'course_info'},
{'type':'textbooks'},
]
@staticmethod
def _patch_reverse(course):
def patched_reverse(viewname, args):
if viewname == "django_comment_client.forum.views.forum_form_discussion" and args == [course.id]:
return "default_discussion_link"
else:
return None
return patch("courseware.tabs.reverse", patched_reverse)
@patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": False})
def test_explicit_discussion_link(self):
"""Test that setting discussion_link overrides everything else"""
course = CourseFactory.create(discussion_link="other_discussion_link", tabs=self.tabs_with_discussion)
self.assertEqual(tabs.get_discussion_link(course), "other_discussion_link")
@patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": False})
def test_discussions_disabled(self):
"""Test that other cases return None with discussions disabled"""
for i, t in enumerate([None, self.tabs_with_discussion, self.tabs_without_discussion]):
course = CourseFactory.create(tabs=t, number=str(i))
self.assertEqual(tabs.get_discussion_link(course), None)
@patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_no_tabs(self):
"""Test a course without tabs configured"""
course = CourseFactory.create(tabs=None)
with self._patch_reverse(course):
self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link")
@patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_tabs_with_discussion(self):
"""Test a course with a discussion tab configured"""
course = CourseFactory.create(tabs=self.tabs_with_discussion)
with self._patch_reverse(course):
self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link")
@patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_tabs_without_discussion(self):
"""Test a course with tabs configured but without a discussion tab"""
course = CourseFactory.create(tabs=self.tabs_without_discussion)
self.assertEqual(tabs.get_discussion_link(course), None)
......@@ -49,7 +49,6 @@ class TestGradebook(ModuleStoreTestCase):
]
for user in self.users:
UserProfileFactory.create(user=user)
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
for i in xrange(USER_COUNT-1):
......
......@@ -88,6 +88,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.
......@@ -123,3 +125,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',
)
......@@ -202,5 +202,62 @@ mark {
}
}
.help-tab {
@include transform(rotate(-90deg));
@include transform-origin(0 0);
top: 50%;
left: 0;
position: fixed;
z-index: 99;
a:link, a:visited {
cursor: pointer;
border: 1px solid #ccc;
border-top-style: none;
@include border-radius(0px 0px 10px 10px);
background: transparentize(#fff, 0.25);
color: transparentize(#333, 0.25);
font-weight: bold;
text-decoration: none;
padding: 6px 22px 11px;
display: inline-block;
&:hover {
color: #fff;
background: #1D9DD9;
}
}
}
.help-buttons {
padding: 10px 50px;
a:link, a:visited {
padding: 15px 0px;
text-align: center;
cursor: pointer;
background: #fff;
text-decoration: none;
display: block;
border: 1px solid #ccc;
&#feedback_link_problem {
border-bottom-style: none;
@include border-radius(10px 10px 0px 0px);
}
&#feedback_link_question {
border-top-style: none;
@include border-radius(0px 0px 10px 10px);
}
&:hover {
color: #fff;
background: #1D9DD9;
}
}
}
#feedback_form textarea[name="details"] {
height: 150px;
}
......@@ -155,7 +155,7 @@
display: block;
color: #8F0E0E;
+ input {
+ input, + textarea {
border: 1px solid #CA1111;
color: #8F0E0E;
}
......
<%namespace name='static' file='static_content.html'/>
<%! from django.conf import settings %>
<%! from courseware.tabs import get_discussion_link %>
% if settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False):
<div class="help-tab">
<a href="#help-modal" rel="leanModal">Help</a>
</div>
<section id="help-modal" class="modal">
<div class="inner-wrapper" id="help_wrapper">
<header>
<h2><span class="edx">edX</span> Help</h2>
<hr>
</header>
<%
discussion_link = get_discussion_link(course) if course else None
%>
% if discussion_link:
<p>
Have a course-specific question?
<a href="${discussion_link}" target="_blank"/>
Post it on the course forums.
</a>
</p>
<hr>
% endif
<p>Have a general question about edX? <a href="/help" target="_blank">Check the FAQ</a>.</p>
<hr>
<div class="help-buttons">
<a href="#" id="feedback_link_problem">Report a problem</a>
<a href="#" id="feedback_link_suggestion">Make a suggestion</a>
<a href="#" id="feedback_link_question">Ask a question</a>
</div>
## TODO: find a way to refactor this
<div class="close-modal">
<div class="inner">
<p>&#10005;</p>
</div>
</div>
</div>
<div class="inner-wrapper" id="feedback_form_wrapper">
<header></header>
<form id="feedback_form" class="feedback_form" method="post" data-remote="true" action="/submit_feedback">
<div id="feedback_error" class="modal-form-error"></div>
% if not user.is_authenticated():
<label data-field="name">Name*</label>
<input name="name" type="text">
<label data-field="email">E-mail*</label>
<input name="email" type="text">
% endif
<label data-field="subject">Subject*</label>
<input name="subject" type="text">
<label data-field="details">Details*</label>
<textarea name="details"></textarea>
<input name="tag" type="hidden">
<div class="submit">
<input name="submit" type="submit" value="Submit">
</div>
</form>
<div class="close-modal">
<div class="inner">
<p>&#10005;</p>
</div>
</div>
</div>
<div class="inner-wrapper" id="feedback_success_wrapper">
<header>
<h2>Thank You!</h2>
<hr>
</header>
<p>
Thanks for your feedback. We will read your message, and our
support team may contact you to respond or ask for further clarification.
</p>
<div class="close-modal">
<div class="inner">
<p>&#10005;</p>
</div>
</div>
</div>
</section>
<script type="text/javascript">
(function() {
$(".help-tab").click(function() {
$(".field-error").removeClass("field-error");
$("#feedback_form")[0].reset();
$("#feedback_form input[type='submit']").removeAttr("disabled");
$("#feedback_form_wrapper").css("display", "none");
$("#feedback_error").css("display", "none");
$("#feedback_success_wrapper").css("display", "none");
$("#help_wrapper").css("display", "block");
});
showFeedback = function(e, tag, title) {
$("#help_wrapper").css("display", "none");
$("#feedback_form input[name='tag']").val(tag);
$("#feedback_form_wrapper").css("display", "block");
$("#feedback_form_wrapper header").html("<h2>" + title + "</h2><hr>");
e.preventDefault();
};
$("#feedback_link_problem").click(function(e) {
showFeedback(e, "problem", "Report a Problem");
});
$("#feedback_link_suggestion").click(function(e) {
showFeedback(e, "suggestion", "Make a Suggestion");
});
$("#feedback_link_question").click(function(e) {
showFeedback(e, "question", "Ask a Question");
});
$("#feedback_form").submit(function() {
$("input[type='submit']", this).attr("disabled", "disabled");
});
$("#feedback_form").on("ajax:complete", function() {
$("input[type='submit']", this).removeAttr("disabled");
});
$("#feedback_form").on("ajax:success", function(event, data, status, xhr) {
$("#feedback_form_wrapper").css("display", "none");
$("#feedback_success_wrapper").css("display", "block");
});
$("#feedback_form").on("ajax:error", function(event, xhr, status, error) {
$(".field-error").removeClass("field-error");
var responseData;
try {
responseData = jQuery.parseJSON(xhr.responseText);
} catch(err) {
}
if (responseData) {
$("[data-field='"+responseData.field+"']").addClass("field-error");
$("#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.";
% if settings.FEEDBACK_SUBMISSION_EMAIL:
htmlStr += " Please <a href='#' id='feedback_email'>send us e-mail</a>.";
% 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.";
% 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}" +
"?subject=" + $("#feedback_form input[name='subject']").val() +
"&body=" + $("#feedback_form textarea[name='details']").val();
window.open(mailto);
e.preventDefault();
});
%endif
}
});
})(this)
</script>
%endif
......@@ -96,3 +96,5 @@ site_status_msg = get_site_status_msg(course_id)
<%include file="signup_modal.html" />
<%include file="forgot_password_modal.html" />
%endif
<%include file="help_modal.html"/>
......@@ -114,8 +114,9 @@ urlpatterns = ('', # nopep8
# 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