278 lines
9.7 KiB
Python
278 lines
9.7 KiB
Python
import json
|
|
import logging
|
|
import sys
|
|
from functools import wraps
|
|
|
|
from django.conf import settings
|
|
from django.core.validators import ValidationError, validate_email
|
|
from django.views.decorators.csrf import requires_csrf_token
|
|
from django.views.defaults import server_error
|
|
from django.http import (Http404, HttpResponse, HttpResponseNotAllowed,
|
|
HttpResponseServerError)
|
|
import dogstats_wrapper as dog_stats_api
|
|
from edxmako.shortcuts import render_to_response
|
|
import zendesk
|
|
from microsite_configuration import microsite
|
|
|
|
import calc
|
|
import track.views
|
|
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def ensure_valid_course_key(view_func):
|
|
"""
|
|
This decorator should only be used with views which have argument course_key_string (studio) or course_id (lms).
|
|
If course_key_string (studio) or course_id (lms) is not valid raise 404.
|
|
"""
|
|
@wraps(view_func)
|
|
def inner(request, *args, **kwargs):
|
|
course_key = kwargs.get('course_key_string') or kwargs.get('course_id')
|
|
if course_key is not None:
|
|
try:
|
|
CourseKey.from_string(course_key)
|
|
except InvalidKeyError:
|
|
raise Http404
|
|
|
|
response = view_func(request, *args, **kwargs)
|
|
return response
|
|
|
|
return inner
|
|
|
|
|
|
@requires_csrf_token
|
|
def jsonable_server_error(request, template_name='500.html'):
|
|
"""
|
|
500 error handler that serves JSON on an AJAX request, and proxies
|
|
to the Django default `server_error` view otherwise.
|
|
"""
|
|
if request.is_ajax():
|
|
msg = {"error": "The edX servers encountered an error"}
|
|
return HttpResponseServerError(json.dumps(msg))
|
|
else:
|
|
return server_error(request, template_name=template_name)
|
|
|
|
|
|
def calculate(request):
|
|
''' Calculator in footer of every page. '''
|
|
equation = request.GET['equation']
|
|
try:
|
|
result = calc.evaluator({}, {}, equation)
|
|
except:
|
|
event = {'error': map(str, sys.exc_info()),
|
|
'equation': equation}
|
|
track.views.server_track(request, 'error:calc', event, page='calc')
|
|
return HttpResponse(json.dumps({'result': 'Invalid syntax'}))
|
|
return HttpResponse(json.dumps({'result': str(result)}))
|
|
|
|
|
|
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,
|
|
# 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 _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info):
|
|
"""
|
|
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.
|
|
"""
|
|
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)
|
|
)
|
|
|
|
# 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 white label feedback items
|
|
# via tagging
|
|
white_label_org = microsite.get_value('course_org_filter')
|
|
if white_label_org:
|
|
zendesk_tags = zendesk_tags + ["whitelabel_{org}".format(org=white_label_org)]
|
|
|
|
new_ticket = {
|
|
"ticket": {
|
|
"requester": {"name": realname, "email": email},
|
|
"subject": subject,
|
|
"comment": {"body": details},
|
|
"tags": zendesk_tags
|
|
}
|
|
}
|
|
try:
|
|
ticket_id = zendesk_api.create_ticket(new_ticket)
|
|
except zendesk.ZendeskError as err:
|
|
log.error("Error creating Zendesk ticket: %s", str(err))
|
|
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 as err:
|
|
log.error("Error updating Zendesk ticket: %s", str(err))
|
|
# The update is not strictly necessary, so do not indicate failure to the user
|
|
pass
|
|
|
|
return True
|
|
|
|
|
|
DATADOG_FEEDBACK_METRIC = "lms_feedback_submissions"
|
|
|
|
|
|
def _record_feedback_in_datadog(tags):
|
|
datadog_tags = [u"{k}:{v}".format(k=k, v=v) for k, v in tags.items()]
|
|
dog_stats_api.increment(DATADOG_FEEDBACK_METRIC, tags=datadog_tags)
|
|
|
|
|
|
def submit_feedback(request):
|
|
"""
|
|
Create a new user-requested ticket, currently implemented with Zendesk.
|
|
|
|
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"])
|
|
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 = dict(
|
|
[(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if tag in request.POST]
|
|
)
|
|
|
|
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, pretty in [
|
|
("HTTP_REFERER", "Page"),
|
|
("HTTP_USER_AGENT", "Browser"),
|
|
("REMOTE_ADDR", "Client IP"),
|
|
("SERVER_NAME", "Host")
|
|
]:
|
|
additional_info[pretty] = request.META.get(header)
|
|
|
|
success = _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info)
|
|
_record_feedback_in_datadog(tags)
|
|
|
|
return HttpResponse(status=(200 if success else 500))
|
|
|
|
|
|
def info(request):
|
|
''' Info page (link from main header) '''
|
|
return render_to_response("info.html", {})
|
|
|
|
|
|
# From http://djangosnippets.org/snippets/1042/
|
|
def parse_accept_header(accept):
|
|
"""Parse the Accept header *accept*, returning a list with pairs of
|
|
(media_type, q_value), ordered by q values.
|
|
"""
|
|
result = []
|
|
for media_range in accept.split(","):
|
|
parts = media_range.split(";")
|
|
media_type = parts.pop(0)
|
|
media_params = []
|
|
q = 1.0
|
|
for part in parts:
|
|
(key, value) = part.lstrip().split("=", 1)
|
|
if key == "q":
|
|
q = float(value)
|
|
else:
|
|
media_params.append((key, value))
|
|
result.append((media_type, tuple(media_params), q))
|
|
result.sort(lambda x, y: -cmp(x[2], y[2]))
|
|
return result
|
|
|
|
|
|
def accepts(request, media_type):
|
|
"""Return whether this request has an Accept header that matches type"""
|
|
accept = parse_accept_header(request.META.get("HTTP_ACCEPT", ""))
|
|
return media_type in [t for (t, p, q) in accept]
|