views.py 15.4 KB
Newer Older
Piotr Mitros committed
1
import json
2
import logging
3
import sys
4
from functools import wraps
5

Piotr Mitros committed
6
from django.conf import settings
7
from django.contrib.auth.decorators import login_required
Bill DeRusha committed
8
from django.core.cache import caches
9
from django.core.validators import ValidationError, validate_email
10 11 12
from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import server_error
from django.http import (Http404, HttpResponse, HttpResponseNotAllowed,
13
                         HttpResponseServerError, HttpResponseForbidden)
14
import dogstats_wrapper as dog_stats_api
David Baumgold committed
15
from edxmako.shortcuts import render_to_response
16
import zendesk
17
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
18

19
import calc
20
import track.views
Piotr Mitros committed
21

22 23
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
24

25 26
from student.roles import GlobalStaff

27 28 29
log = logging.getLogger(__name__)


30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
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


50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
def require_global_staff(func):
    """View decorator that requires that the user have global staff permissions. """
    @wraps(func)
    def wrapped(request, *args, **kwargs):  # pylint: disable=missing-docstring
        if GlobalStaff().has_user(request.user):
            return func(request, *args, **kwargs)
        else:
            return HttpResponseForbidden(
                u"Must be {platform_name} staff to perform this action.".format(
                    platform_name=settings.PLATFORM_NAME
                )
            )
    return login_required(wrapped)


65 66 67 68 69 70 71 72 73 74 75 76 77
@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)


78
def handle_500(template_path, context=None, test_func=None):
79 80
    """
    Decorator for view specific 500 error handling.
81
    Custom handling will be skipped only if test_func is passed and it returns False
82

83
    Usage:
84

85 86 87 88 89
        @handle_500(
            template_path='certificates/server-error.html',
            context={'error-info': 'Internal Server Error'},
            test_func=lambda request: request.GET.get('preview', None)
        )
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
        def my_view(request):
            # Any unhandled exception in this view would be handled by the handle_500 decorator
            # ...

    """
    def decorator(func):
        """
        Decorator to render custom html template in case of uncaught exception in wrapped function
        """
        @wraps(func)
        def inner(request, *args, **kwargs):
            """
            Execute the function in try..except block and return custom server-error page in case of unhandled exception
            """
            try:
                return func(request, *args, **kwargs)
            except Exception:  # pylint: disable=broad-except
                if settings.DEBUG:
                    # In debug mode let django process the 500 errors and display debug info for the developer
                    raise
110 111 112 113
                elif test_func is None or test_func(request):
                    # Display custom 500 page if either
                    #   1. test_func is None (meaning nothing to test)
                    #   2. or test_func(request) returns True
114
                    log.exception("Error in django view.")
115
                    return render_to_response(template_path, context)
116 117 118
                else:
                    # Do not show custom 500 error when test fails
                    raise
119 120 121 122
        return inner
    return decorator


Piotr Mitros committed
123
def calculate(request):
124
    ''' Calculator in footer of every page. '''
Piotr Mitros committed
125
    equation = request.GET['equation']
126
    try:
127
        result = calc.evaluator({}, {}, equation)
Piotr Mitros committed
128
    except:
129 130
        event = {'error': map(str, sys.exc_info()),
                 'equation': equation}
131
        track.views.server_track(request, 'error:calc', event, page='calc')
132 133 134
        return HttpResponse(json.dumps({'result': 'Invalid syntax'}))
    return HttpResponse(json.dumps({'result': str(result)}))

Piotr Mitros committed
135

136
class _ZendeskApi(object):
Bill DeRusha committed
137 138 139 140

    CACHE_PREFIX = 'ZENDESK_API_CACHE'
    CACHE_TIMEOUT = 60 * 60

141 142 143 144 145 146 147 148 149 150 151 152
    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,
153 154 155 156
            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}
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
        )

    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)

Bill DeRusha committed
176 177 178 179 180 181 182
    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.
183

Bill DeRusha committed
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
        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 _record_feedback_in_zendesk(
        realname,
        email,
        subject,
        details,
        tags,
        additional_info,
        group_name=None,
207 208
        require_update=False,
        support_email=None
Bill DeRusha committed
209
):
210 211 212
    """
    Create a new user-requested Zendesk ticket.

213 214 215 216
    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.
Bill DeRusha committed
217 218 219 220 221 222

    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.
223 224 225 226
    """
    zendesk_api = _ZendeskApi()

    additional_info_string = (
227 228
        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)
229 230
    )

231 232
    # Tag all issues with LMS to distinguish channel in Zendesk; requested by student support team
    zendesk_tags = list(tags.values()) + ["LMS"]
233 234 235

    # Per edX support, we would like to be able to route white label feedback items
    # via tagging
236
    white_label_org = configuration_helpers.get_value('course_org_filter')
237 238 239
    if white_label_org:
        zendesk_tags = zendesk_tags + ["whitelabel_{org}".format(org=white_label_org)]

240 241 242 243 244 245 246 247
    new_ticket = {
        "ticket": {
            "requester": {"name": realname, "email": email},
            "subject": subject,
            "comment": {"body": details},
            "tags": zendesk_tags
        }
    }
Bill DeRusha committed
248 249 250 251 252
    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']
253 254 255 256 257
    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
258 259
    try:
        ticket_id = zendesk_api.create_ticket(new_ticket)
260
        if group_name is not None and group is None:
Bill DeRusha committed
261 262 263 264
            # 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)
265 266
    except zendesk.ZendeskError:
        log.exception("Error creating Zendesk ticket")
267 268 269 270 271 272 273
        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)
274
    except zendesk.ZendeskError:
Bill DeRusha committed
275 276 277 278 279 280
        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
281 282 283 284 285 286 287
    return True


DATADOG_FEEDBACK_METRIC = "lms_feedback_submissions"


def _record_feedback_in_datadog(tags):
288
    datadog_tags = [u"{k}:{v}".format(k=k, v=v) for k, v in tags.items()]
289 290 291 292 293 294 295 296
    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`.
297 298 299 300 301 302 303
    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
304 305 306
    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.
307
    """
308
    if not settings.FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False):
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339
        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"]
340
    tags = dict(
341
        [(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if tag in request.POST]
342
    )
343 344 345

    if request.user.is_authenticated():
        realname = request.user.profile.name
346
        email = request.user.email
347 348 349 350 351 352 353 354 355
        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"])

356 357 358 359 360 361 362
    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)
363

364 365 366 367 368 369 370
    success = _record_feedback_in_zendesk(
        realname,
        email,
        subject,
        details,
        tags,
        additional_info,
371
        support_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
372
    )
373
    _record_feedback_in_datadog(tags)
374

375
    return HttpResponse(status=(200 if success else 500))
376

Piotr Mitros committed
377 378

def info(request):
379
    ''' Info page (link from main header) '''
Piotr Mitros committed
380
    return render_to_response("info.html", {})
381

382

383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
# 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

404

405 406 407 408
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]
409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427


def add_p3p_header(view_func):
    """
    This decorator should only be used with views which may be displayed through the iframe.
    It adds additional headers to response and therefore gives IE browsers an ability to save cookies inside the iframe
    Details:
    http://blogs.msdn.com/b/ieinternals/archive/2013/09/17/simple-introduction-to-p3p-cookie-blocking-frame.aspx
    http://stackoverflow.com/questions/8048306/what-is-the-most-broad-p3p-header-that-will-work-with-ie
    """
    @wraps(view_func)
    def inner(request, *args, **kwargs):
        """
        Helper function
        """
        response = view_func(request, *args, **kwargs)
        response['P3P'] = settings.P3P_HEADER
        return response
    return inner